diff --git a/app/controlplane/pkg/biz/casmapping.go b/app/controlplane/pkg/biz/casmapping.go index 2cc2ad3ff..b96ab7403 100644 --- a/app/controlplane/pkg/biz/casmapping.go +++ b/app/controlplane/pkg/biz/casmapping.go @@ -142,7 +142,7 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, if err != nil { return nil, fmt.Errorf("failed to find cas mapping in orgs: %w", err) } else if mapping == nil { - uc.logger.Warnw("msg", "digest not accessible to the requesting orgs", "digest", digest, "orgs", orgs) + uc.logger.Warnw("msg", "digest not accessible to the requesting orgs", "digest", digest, "orgs", orgs, "projectIDs", projectIDs) return nil, NewErrNotFound("digest not found in any mapping") } diff --git a/app/controlplane/pkg/biz/casmapping_integration_test.go b/app/controlplane/pkg/biz/casmapping_integration_test.go index 909dce304..610d01b0a 100644 --- a/app/controlplane/pkg/biz/casmapping_integration_test.go +++ b/app/controlplane/pkg/biz/casmapping_integration_test.go @@ -18,6 +18,7 @@ package biz_test import ( "context" "testing" + "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" @@ -253,11 +254,29 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadSkipsSoftDeleted() } func (s *casMappingIntegrationSuite) TestCreate() { + ctx := context.Background() + + // A project version ID is not a project ID. Storing one in cas_mappings.project_id makes the + // mapping unreachable for any role whose downloads are filtered by project RBAC. + projectVersion, err := s.ProjectVersion.Create(ctx, s.projectID.String(), "v1.0.0", false) + require.NoError(s.T(), err) + + // A project living in a different organization than the CAS backend. + foreignProject, err := s.Project.Create(ctx, s.org2.ID, randomName()) + require.NoError(s.T(), err) + + // A soft-deleted project. Nothing sets projects.deleted_at today, so it is set directly here to + // cover the guard against a project that is no longer live. + deletedProject, err := s.Project.Create(ctx, s.org1.ID, randomName()) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Data.DB.Project.UpdateOneID(deletedProject.ID).SetDeletedAt(time.Now()).Exec(ctx)) + testCases := []struct { name string digest string casBackendID uuid.UUID workflowRunID *uuid.UUID + projectID *uuid.UUID wantErr bool }{ { @@ -311,6 +330,40 @@ func (s *casMappingIntegrationSuite) TestCreate() { digest: validDigest, casBackendID: s.casBackend1.ID, }, + { + name: "associated to a project", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(s.projectID), + }, + { + name: "non-existing project", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(uuid.New()), + wantErr: true, + }, + { + name: "a project version ID is not a valid project ID", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(projectVersion.ID), + wantErr: true, + }, + { + name: "project from another organization", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(foreignProject.ID), + wantErr: true, + }, + { + name: "soft-deleted project", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(deletedProject.ID), + wantErr: true, + }, } for _, tc := range testCases { @@ -324,8 +377,12 @@ func (s *casMappingIntegrationSuite) TestCreate() { want.WorkflowRunID = *tc.workflowRunID } + if tc.projectID != nil { + want.ProjectID = *tc.projectID + } + s.Run(tc.name, func() { - got, err := s.CASMapping.Create(context.TODO(), tc.digest, tc.casBackendID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: tc.workflowRunID}) + got, err := s.CASMapping.Create(ctx, tc.digest, tc.casBackendID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: tc.workflowRunID, ProjectID: tc.projectID}) if tc.wantErr { s.Error(err) } else { diff --git a/app/controlplane/pkg/data/casmapping.go b/app/controlplane/pkg/data/casmapping.go index d75b0d1f6..cbc7d0add 100644 --- a/app/controlplane/pkg/data/casmapping.go +++ b/app/controlplane/pkg/data/casmapping.go @@ -25,6 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/casbackend" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/casmapping" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflowrun" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/go-kratos/kratos/v2/log" @@ -69,6 +70,24 @@ func (r *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID } } + // project_id is not enforced at the database level, so an ID that belongs to no project (a + // project version ID, for instance) writes cleanly. Validate it here: a mapping whose project_id + // is not a live project of the backend's organization can never match the + // "organization_id = AND project_id IN ()" filter used by + // FindByDigestInOrgs, making the artifact undownloadable for every role with RBAC enabled. + if opts != nil && opts.ProjectID != nil { + exists, err := r.data.DB.Project.Query().Where( + project.ID(*opts.ProjectID), + project.OrganizationID(casBackend.OrganizationID), + project.DeletedAtIsNil(), + ).Exist(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check project: %w", err) + } else if !exists { + return nil, biz.NewErrNotFound(fmt.Sprintf("project %s in organization %s", opts.ProjectID, casBackend.OrganizationID)) + } + } + query := r.data.DB.CASMapping.Create(). SetDigest(digest). SetCasBackendID(casBackendID).