From 08187926a0150a9424072769a74338e23ded76fa Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Wed, 19 Aug 2026 16:30:20 +0200 Subject: [PATCH 1/2] fix: validate project_id when creating CAS mappings cas_mappings.project_id is not enforced at the database level, so CASMappingRepo.Create accepted any UUID, including a project version ID. Such a mapping never matches the project-scoped predicate used to resolve downloads, making the artifact unreachable for roles with RBAC enabled while remaining downloadable for org owners and admins. The referenced project must now exist and belong to the CAS backend's organization, so a bad write fails at creation time. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: e19606bd-40e5-400d-a907-29de5fd17b1a --- app/controlplane/pkg/biz/casmapping.go | 2 +- .../pkg/biz/casmapping_integration_test.go | 45 ++++++++++++++++++- app/controlplane/pkg/data/casmapping.go | 18 ++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) 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..c7bf1edcb 100644 --- a/app/controlplane/pkg/biz/casmapping_integration_test.go +++ b/app/controlplane/pkg/biz/casmapping_integration_test.go @@ -253,11 +253,23 @@ 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) + testCases := []struct { name string digest string casBackendID uuid.UUID workflowRunID *uuid.UUID + projectID *uuid.UUID wantErr bool }{ { @@ -311,6 +323,33 @@ 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, + }, } for _, tc := range testCases { @@ -324,8 +363,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..0e021ee3a 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,23 @@ 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 real 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), + ).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). From 77d32baaada81316d69eccdfbd502634c2d2ad01 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Wed, 19 Aug 2026 16:42:55 +0200 Subject: [PATCH 2/2] fix: reject soft-deleted projects when creating CAS mappings A project row that is no longer live is not a valid target for a new mapping, and is not guaranteed to be reachable by project-filtered downloads. Align the check with the rest of the project lookups in the data layer, which all filter on deleted_at. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: e19606bd-40e5-400d-a907-29de5fd17b1a --- .../pkg/biz/casmapping_integration_test.go | 14 ++++++++++++++ app/controlplane/pkg/data/casmapping.go | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/casmapping_integration_test.go b/app/controlplane/pkg/biz/casmapping_integration_test.go index c7bf1edcb..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" @@ -264,6 +265,12 @@ func (s *casMappingIntegrationSuite) TestCreate() { 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 @@ -350,6 +357,13 @@ func (s *casMappingIntegrationSuite) TestCreate() { 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 { diff --git a/app/controlplane/pkg/data/casmapping.go b/app/controlplane/pkg/data/casmapping.go index 0e021ee3a..cbc7d0add 100644 --- a/app/controlplane/pkg/data/casmapping.go +++ b/app/controlplane/pkg/data/casmapping.go @@ -72,13 +72,14 @@ 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 real project of the backend's organization can never match the + // 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)