From 03d3d87fc49fbc187f6968989b63732d53ce1b04 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 21 Aug 2026 00:48:34 +0200 Subject: [PATCH] feat(controlplane): scope CAS mappings to products Artifacts stored in the CAS are scoped to a project so that the download lookup can honour project RBAC. Evidence attached to a product-level override has no project, so its mapping ends up unscoped and unreachable for any user whose organization role has RBAC enabled. Add an optional product_id to cas_mappings and extend the download filter, so that a mapping is reachable through a project OR a product membership. Product memberships already live in this database, written by the Chainloop platform, so the filter only needs to read rows it already stores. The column is additive and inert on its own: nothing here writes it, and an all-NULL column changes no existing query result. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: d81e130c-5011-4bf4-99bc-9902a6fafb43 --- .../internal/service/cascredential.go | 10 +- app/controlplane/pkg/biz/casmapping.go | 98 +++++-- .../pkg/biz/casmapping_integration_test.go | 242 +++++++++++++++--- app/controlplane/pkg/biz/membership.go | 74 +++++- .../pkg/biz/mocks/CASMappingRepo.go | 104 ++++++-- app/controlplane/pkg/biz/project.go | 12 - app/controlplane/pkg/biz/referrer.go | 7 +- app/controlplane/pkg/data/casmapping.go | 75 ++++-- app/controlplane/pkg/data/ent/casmapping.go | 13 +- .../pkg/data/ent/casmapping/casmapping.go | 8 + .../pkg/data/ent/casmapping/where.go | 55 ++++ .../pkg/data/ent/casmapping_create.go | 24 ++ .../pkg/data/ent/casmapping_update.go | 6 + .../ent/migrate/migrations/20260820221508.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 9 +- app/controlplane/pkg/data/ent/mutation.go | 75 +++++- .../pkg/data/ent/schema/casmapping.go | 6 +- 18 files changed, 697 insertions(+), 126 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260820221508.sql diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 48024e480..b354590c2 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -115,12 +115,12 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS return nil, handleUseCaseErr(err, s.log) } - // pass projectIDs if it's included in the token - projectIDs := make(map[uuid.UUID][]uuid.UUID) - if currentAPIToken.ProjectID != nil { - projectIDs[orgID] = []uuid.UUID{*currentAPIToken.ProjectID} + // restrict the lookup to what the token can see, a nil result meaning no RBAC applies + scopes := make(biz.RBACScopes) + if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil { + scopes[orgID] = biz.RBACScope{ProjectIDs: visibleProjects} } - mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, projectIDs) + mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, scopes) if err != nil && !biz.IsNotFound(err) { if biz.IsErrValidation(err) { return nil, errors.BadRequest("invalid", err.Error()) diff --git a/app/controlplane/pkg/biz/casmapping.go b/app/controlplane/pkg/biz/casmapping.go index b96ab7403..1ff97ac04 100644 --- a/app/controlplane/pkg/biz/casmapping.go +++ b/app/controlplane/pkg/biz/casmapping.go @@ -36,20 +36,32 @@ type CASMapping struct { CASBackend *CASBackend Digest string CreatedAt *time.Time - ProjectID uuid.UUID -} - -type CASMappingFindOptions struct { - Orgs []uuid.UUID - ProjectIDs []uuid.UUID + // Scope of the artifact within the organization. At most one of them is set, both being unset + // means the mapping is only reachable with organization-wide access. + // + // The two scopes are not symmetric: projects live in this database and ProjectID is backed by a + // foreign key, while products live in the Chainloop platform database, so ProductID is a plain + // UUID reference. The download filter still treats both as equal grants, see RBACScope. + ProjectID uuid.UUID + ProductID uuid.UUID } type CASMappingRepo interface { // Create a mapping with an optional workflow run id Create(ctx context.Context, digest string, casBackendID uuid.UUID, opts *CASMappingCreateOpts) (*CASMapping, error) // FindByDigestInOrgs returns a single accessible mapping for the digest within the given orgs - // (honouring project RBAC), preferring the default backend. Returns (nil, nil) when none exists. - FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (*CASMapping, error) + // (honouring the RBAC scopes), preferring the default backend. Returns (nil, nil) when none exists. + FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, scopes RBACScopes) (*CASMapping, error) + // ListByDigestInOrg returns the mappings for the digest in the given org, with no RBAC + // filtering applied. Mappings whose CAS backend has been (soft) deleted are left out, since + // they can no longer serve the artifact, so an empty slice means either no mapping exists or + // none of them can still be served. + // + // It has no consumer in this repository: the Chainloop platform reconciles product-scoped + // mappings for evidence uploaded before this scope existed, and needs to know which scopes and + // backends an artifact already has. Kept here because it must ship in the same release the + // platform bumps to for the product scope itself. + ListByDigestInOrg(ctx context.Context, digest string, orgID uuid.UUID) ([]*CASMapping, error) } type CASMappingUseCase struct { @@ -64,7 +76,9 @@ func NewCASMappingUseCase(repo CASMappingRepo, membershipUC *MembershipUseCase, type CASMappingCreateOpts struct { WorkflowRunID *uuid.UUID - ProjectID *uuid.UUID + // Scope of the artifact, see the CASMapping fields of the same name. + ProjectID *uuid.UUID + ProductID *uuid.UUID } // Create a mapping with an optional workflow run id @@ -77,17 +91,31 @@ func (uc *CASMappingUseCase) Create(ctx context.Context, digest string, casBacke return nil, NewErrInvalidUUID(err) } - // parse the digest to make sure is a valid sha256 sum - if _, err = cr_v1.NewHash(digest); err != nil { - return nil, NewErrValidation(fmt.Errorf("invalid digest format: %w", err)) + if err := validateDigest(digest); err != nil { + return nil, err + } + + // The download filter grants access on either scope, so a mapping carrying both would be + // reachable by members of the project AND members of the unrelated product. + if opts != nil && opts.ProjectID != nil && opts.ProductID != nil { + return nil, NewErrValidationStr("a mapping cannot be scoped to a project and a product at once") } return uc.repo.Create(ctx, digest, casBackendUUID, opts) } +// validateDigest makes sure the digest is a valid sha256 sum +func validateDigest(digest string) error { + if _, err := cr_v1.NewHash(digest); err != nil { + return NewErrValidation(fmt.Errorf("invalid digest format: %w", err)) + } + + return nil +} + // FindCASMappingForDownloadByUser returns the CASMapping appropriate for the given digest and user. -// It returns a mapping that points to an organization the user is a member of (honoring project -// RBAC); if there are multiple, it picks the default one or the first one. +// It returns a mapping that points to an organization the user is a member of (honoring the user's +// RBAC scopes); if there are multiple, it picks the default one or the first one. func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context, digest string, userID string) (*CASMapping, error) { ctx, span := otelx.Start(ctx, casMappingTracer, "CASMappingUseCase.FindCASMappingForDownloadByUser") defer span.End() @@ -99,12 +127,12 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context return nil, NewErrInvalidUUID(err) } - userOrgs, projectIDs, err := uc.membershipUC.GetOrgsAndRBACInfoForUser(ctx, userUUID) + userOrgs, scopes, err := uc.membershipUC.GetOrgsAndRBACInfoForUser(ctx, userUUID) if err != nil { return nil, err } - mapping, err := uc.FindCASMappingForDownloadByOrg(ctx, digest, userOrgs, projectIDs) + mapping, err := uc.FindCASMappingForDownloadByOrg(ctx, digest, userOrgs, scopes) if err != nil { return nil, fmt.Errorf("failed to find cas mapping for download: %w", err) } @@ -113,13 +141,13 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context } // FindCASMappingForDownloadByOrg looks for the CAS mapping to download the referenced artifact in one of the passed organizations. -// The result will get filtered out if RBAC is enabled (projectIDs is not Nil) -func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (result *CASMapping, err error) { +// The result will get filtered out for those organizations RBAC is enabled for, i.e. those present in scopes. +func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, digest string, orgs []uuid.UUID, scopes RBACScopes) (result *CASMapping, err error) { ctx, span := otelx.Start(ctx, casMappingTracer, "CASMappingUseCase.FindCASMappingForDownloadByOrg") defer span.End() - if _, err := cr_v1.NewHash(digest); err != nil { - return nil, NewErrValidation(fmt.Errorf("invalid digest format: %w", err)) + if err := validateDigest(digest); err != nil { + return nil, err } // log the result @@ -135,20 +163,44 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, return nil, NewErrValidationStr("no organizations provided") } - // A mapping reachable through one of the user's orgs (honouring project RBAC), selected and + // A mapping reachable through one of the user's orgs (honouring the RBAC scopes), selected and // bounded in the database. This is the common path and stays cheap regardless of how many // mappings a digest has accumulated. - mapping, err := uc.repo.FindByDigestInOrgs(ctx, digest, orgs, projectIDs) + mapping, err := uc.repo.FindByDigestInOrgs(ctx, digest, orgs, scopes) 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, "projectIDs", projectIDs) + uc.logger.Warnw("msg", "digest not accessible to the requesting orgs", "digest", digest, "orgs", orgs, "scopes", scopes) return nil, NewErrNotFound("digest not found in any mapping") } return mapping, nil } +// ListByDigestInOrg returns the servable mappings for the digest within the given organization, +// with no RBAC filtering. It is meant for trusted, system-level callers that need to inspect the +// existing scopes of an artifact, or the backends holding it, rather than to serve a download. +// Mappings on a (soft) deleted backend are left out, see the repository method. +func (uc *CASMappingUseCase) ListByDigestInOrg(ctx context.Context, digest string, orgID uuid.UUID) ([]*CASMapping, error) { + ctx, span := otelx.Start(ctx, casMappingTracer, "CASMappingUseCase.ListByDigestInOrg") + defer span.End() + + if err := validateDigest(digest); err != nil { + return nil, err + } + + if orgID == uuid.Nil { + return nil, NewErrValidationStr("organization ID cannot be empty") + } + + mappings, err := uc.repo.ListByDigestInOrg(ctx, digest, orgID) + if err != nil { + return nil, fmt.Errorf("failed to list cas mappings in org: %w", err) + } + + return mappings, nil +} + type CASMappingLookupRef struct { Name, Digest string } diff --git a/app/controlplane/pkg/biz/casmapping_integration_test.go b/app/controlplane/pkg/biz/casmapping_integration_test.go index 610d01b0a..9301f2c3c 100644 --- a/app/controlplane/pkg/biz/casmapping_integration_test.go +++ b/app/controlplane/pkg/biz/casmapping_integration_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" creds "github.com/chainloop-dev/chainloop/pkg/credentials/mocks" @@ -192,39 +193,187 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadPrefersDefaultBack }) } -// When RBAC is enabled for an org (projectIDs carries an entry for it), only mappings whose project -// is in the visible set are reachable through that org. +// When RBAC is enabled for an org (scopes carries an entry for it), only mappings scoped to a +// project or a product in the visible set are reachable through that org. func (s *casMappingIntegrationSuite) TestCASMappingForDownloadRBAC() { ctx := context.Background() orgUUID := uuid.MustParse(s.org1.ID) - // A mapping in org1 scoped to a specific project. + // A mapping in org1 scoped to a specific project, and one scoped to a specific product. _, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ WorkflowRunID: &s.workflowRun.ID, ProjectID: &s.projectID, }) require.NoError(s.T(), err) - s.Run("returned when the project is visible", func() { - mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID}, - map[uuid.UUID][]uuid.UUID{orgUUID: {s.projectID}}) + _, err = s.CASMapping.Create(ctx, validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ + ProductID: &s.productID, + }) + require.NoError(s.T(), err) + + testCases := []struct { + name string + digest string + scope biz.RBACScope + want bool + }{ + { + name: "project mapping returned when the project is visible", + digest: validDigest, + scope: biz.RBACScope{ProjectIDs: []uuid.UUID{s.projectID}}, + want: true, + }, + { + name: "project mapping not returned when the project is not visible", + digest: validDigest, + scope: biz.RBACScope{ProjectIDs: []uuid.UUID{uuid.New()}}, + }, + { + name: "project mapping not returned to a product-only member", + digest: validDigest, + scope: biz.RBACScope{ProductIDs: []uuid.UUID{s.productID}}, + }, + { + name: "product mapping returned when the product is visible", + digest: validDigest2, + scope: biz.RBACScope{ProductIDs: []uuid.UUID{s.productID}}, + want: true, + }, + { + name: "product mapping returned when both dimensions are granted", + digest: validDigest2, + scope: biz.RBACScope{ProjectIDs: []uuid.UUID{s.projectID}, ProductIDs: []uuid.UUID{s.productID}}, + want: true, + }, + { + name: "product mapping not returned when the product is not visible", + digest: validDigest2, + scope: biz.RBACScope{ProductIDs: []uuid.UUID{uuid.New()}}, + }, + { + name: "product mapping not returned to a project-only member", + digest: validDigest2, + scope: biz.RBACScope{ProjectIDs: []uuid.UUID{s.projectID}}, + }, + { + name: "project mapping not returned when RBAC is enabled with no grants", + digest: validDigest, + scope: biz.RBACScope{}, + }, + { + name: "product mapping not returned when RBAC is enabled with no grants", + digest: validDigest2, + scope: biz.RBACScope{}, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, tc.digest, []uuid.UUID{orgUUID}, + biz.RBACScopes{orgUUID: tc.scope}) + if !tc.want { + s.Error(err) + s.Nil(mapping) + return + } + + s.NoError(err) + s.Require().NotNil(mapping) + s.Equal(s.casBackend1.ID, mapping.CASBackend.ID) + }) + } +} + +// deref returns the value behind the pointer, or the zero value when it is nil. +func deref[T any](p *T) T { + if p == nil { + var zero T + return zero + } + + return *p +} + +// Product memberships are written into this database by downstream (platform) code only, so this +// test seeds the membership row directly to pin the contract: an org member whose only grant is a +// product membership must reach the artifacts scoped to that product. +func (s *casMappingIntegrationSuite) TestCASMappingForDownloadUserProductMembership() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org1.ID) + + _, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ + ProductID: &s.productID, + }) + require.NoError(s.T(), err) + + s.Run("not reachable before the product membership exists", func() { + mapping, err := s.CASMapping.FindCASMappingForDownloadByUser(ctx, validDigest, s.userOrg1Member.ID) + s.Error(err) + s.Nil(mapping) + }) + + require.NoError(s.T(), s.Repos.Membership.AddResourceRole(ctx, orgUUID, authz.ResourceTypeProduct, s.productID, + authz.MembershipTypeUser, uuid.MustParse(s.userOrg1Member.ID), authz.RoleProductViewer, nil)) + + s.Run("reachable once the product membership exists", func() { + mapping, err := s.CASMapping.FindCASMappingForDownloadByUser(ctx, validDigest, s.userOrg1Member.ID) s.NoError(err) s.Require().NotNil(mapping) - s.Equal(s.casBackend1.ID, mapping.CASBackend.ID) + s.Equal(s.productID, mapping.ProductID) + }) +} + +// ListByDigestInOrg is the unfiltered, system-level view of an artifact's mappings. +func (s *casMappingIntegrationSuite) TestListByDigestInOrg() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org1.ID) + + _, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ + ProjectID: &s.projectID, + }) + require.NoError(s.T(), err) + _, err = s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ + ProductID: &s.productID, + }) + require.NoError(s.T(), err) + // Same digest, different org. + _, err = s.CASMapping.Create(ctx, validDigest, s.casBackend2.ID.String(), nil) + require.NoError(s.T(), err) + + s.Run("returns every scope of the digest within the org", func() { + mappings, err := s.CASMapping.ListByDigestInOrg(ctx, validDigest, orgUUID) + s.NoError(err) + s.Require().Len(mappings, 2) + + type scope struct{ projectID, productID uuid.UUID } + scopes := make([]scope, 0, len(mappings)) + for _, m := range mappings { + s.Equal(orgUUID, m.OrgID) + s.Equal(s.casBackend1.ID, m.CASBackend.ID) + scopes = append(scopes, scope{projectID: m.ProjectID, productID: m.ProductID}) + } + s.ElementsMatch([]scope{ + {projectID: s.projectID}, + {productID: s.productID}, + }, scopes) }) - s.Run("not returned when the project is not visible", func() { - mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID}, - map[uuid.UUID][]uuid.UUID{orgUUID: {uuid.New()}}) + s.Run("returns empty for a digest with no mappings in the org", func() { + mappings, err := s.CASMapping.ListByDigestInOrg(ctx, validDigest3, orgUUID) + s.NoError(err) + s.Empty(mappings) + }) + + s.Run("fails on an invalid digest", func() { + mappings, err := s.CASMapping.ListByDigestInOrg(ctx, invalidDigest, orgUUID) s.Error(err) - s.Nil(mapping) + s.Nil(mappings) }) - s.Run("not returned when RBAC is enabled with no visible projects", func() { - mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID}, - map[uuid.UUID][]uuid.UUID{orgUUID: {}}) + s.Run("fails without an organization", func() { + mappings, err := s.CASMapping.ListByDigestInOrg(ctx, validDigest, uuid.Nil) s.Error(err) - s.Nil(mapping) + s.Nil(mappings) }) } @@ -277,6 +426,7 @@ func (s *casMappingIntegrationSuite) TestCreate() { casBackendID uuid.UUID workflowRunID *uuid.UUID projectID *uuid.UUID + productID *uuid.UUID wantErr bool }{ { @@ -364,25 +514,42 @@ func (s *casMappingIntegrationSuite) TestCreate() { projectID: biz.ToPtr(deletedProject.ID), wantErr: true, }, + { + // Unlike a project, a product cannot be validated here: it lives in the Chainloop + // platform database, so this only checks the ID is stored as given. + name: "scoped to a product", + digest: validDigest, + casBackendID: s.casBackend1.ID, + productID: &s.productID, + }, + { + // The download filter grants access on either scope, so both at once would widen the + // artifact to the members of two unrelated resources. + name: "rejected when scoped to a project and a product at once", + digest: validDigest, + casBackendID: s.casBackend1.ID, + projectID: biz.ToPtr(s.projectID), + productID: &s.productID, + wantErr: true, + }, } for _, tc := range testCases { want := &biz.CASMapping{ - Digest: validDigest, - CASBackend: &biz.CASBackend{ID: s.casBackend1.ID}, - OrgID: s.casBackend1.OrganizationID, - } - - if tc.workflowRunID != nil { - want.WorkflowRunID = *tc.workflowRunID - } - - if tc.projectID != nil { - want.ProjectID = *tc.projectID + Digest: validDigest, + CASBackend: &biz.CASBackend{ID: s.casBackend1.ID}, + OrgID: s.casBackend1.OrganizationID, + WorkflowRunID: deref(tc.workflowRunID), + ProjectID: deref(tc.projectID), + ProductID: deref(tc.productID), } s.Run(tc.name, func() { - got, err := s.CASMapping.Create(ctx, tc.digest, tc.casBackendID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: tc.workflowRunID, ProjectID: tc.projectID}) + got, err := s.CASMapping.Create(ctx, tc.digest, tc.casBackendID.String(), &biz.CASMappingCreateOpts{ + WorkflowRunID: tc.workflowRunID, + ProjectID: tc.projectID, + ProductID: tc.productID, + }) if tc.wantErr { s.Error(err) } else { @@ -402,12 +569,13 @@ func (s *casMappingIntegrationSuite) TestCreate() { type casMappingIntegrationSuite struct { testhelpers.UseCasesEachTestSuite - casBackend1, casBackend2, casBackend3 *biz.CASBackend - workflowRun, publicWorkflowRun *biz.WorkflowRun - publicWorkflow *biz.Workflow - projectID uuid.UUID - userOrg1And2, userOrg2 *biz.User - org1, org2, orgNoUsers *biz.Organization + casBackend1, casBackend2, casBackend3 *biz.CASBackend + workflowRun, publicWorkflowRun *biz.WorkflowRun + publicWorkflow *biz.Workflow + projectID uuid.UUID + productID uuid.UUID + userOrg1And2, userOrg2, userOrg1Member *biz.User + org1, org2, orgNoUsers *biz.Organization } func (s *casMappingIntegrationSuite) SetupTest() { @@ -444,6 +612,7 @@ func (s *casMappingIntegrationSuite) SetupTest() { assert.NoError(err) s.projectID = workflow.ProjectID + s.productID = uuid.New() publicWorkflow, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "test-workflow-public", OrgID: s.org1.ID, Project: "test-project"}) assert.NoError(err) @@ -472,8 +641,15 @@ func (s *casMappingIntegrationSuite) SetupTest() { s.userOrg2, err = s.User.UpsertByEmail(ctx, "foo-org2@test.com", nil) assert.NoError(err) + // A user whose org role has RBAC enabled, so their access is restricted to the projects and + // products they hold a membership on. + s.userOrg1Member, err = s.User.UpsertByEmail(ctx, "member-org1@test.com", nil) + assert.NoError(err) + _, err = s.Membership.Create(ctx, s.org1.ID, s.userOrg1And2.ID) assert.NoError(err) + _, err = s.Membership.Create(ctx, s.org1.ID, s.userOrg1Member.ID, biz.WithMembershipRole(authz.RoleOrgMember)) + assert.NoError(err) _, err = s.Membership.Create(ctx, s.org2.ID, s.userOrg1And2.ID, biz.WithCurrentMembership()) assert.NoError(err) _, err = s.Membership.Create(ctx, s.org2.ID, s.userOrg2.ID, biz.WithCurrentMembership()) diff --git a/app/controlplane/pkg/biz/membership.go b/app/controlplane/pkg/biz/membership.go index 9e347d255..f9aa0ea79 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -452,7 +452,43 @@ func (uc *MembershipUseCase) SetProjectOwner(ctx context.Context, orgID, project return nil } -func (uc *MembershipUseCase) GetOrgsAndRBACInfoForUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, map[uuid.UUID][]uuid.UUID, error) { +// RBACScope holds the resources a subject can reach within a single organization once RBAC is +// enforced for it. Both slices may be empty, meaning the subject reaches nothing in that org. +type RBACScope struct { + // ProjectIDs the subject holds a membership on + ProjectIDs []uuid.UUID + // ProductIDs the subject holds a membership on. Nothing in this repository creates product + // memberships: the Chainloop platform writes them into this same memberships table, because the + // product entity itself lives in the platform database. Dropping this collection silently makes + // product-scoped artifacts undownloadable, so the tests seed such a row explicitly. + ProductIDs []uuid.UUID +} + +// RBACScopes maps an organization to the resources visible within it. An organization present in +// the map has RBAC enforced and access limited to the listed resources, an organization absent from +// the map is reachable in full. +type RBACScopes map[uuid.UUID]RBACScope + +// ProjectIDsByOrg narrows the scopes down to their project dimension, for consumers that only deal +// with project-scoped resources (e.g. referrers). It returns nil for nil scopes, so that callers +// following the "nil means no RBAC" convention keep working. +func (s RBACScopes) ProjectIDsByOrg() map[OrgID][]ProjectID { + if s == nil { + return nil + } + + projectIDs := make(map[OrgID][]ProjectID, len(s)) + for orgID, scope := range s { + projectIDs[orgID] = scope.ProjectIDs + } + + return projectIDs +} + +// GetOrgsAndRBACInfoForUser returns the organizations the user is a member of, together with the +// RBAC scopes of those organizations where the user's role has RBAC enabled. Organizations missing +// from the scopes are reachable in full. +func (uc *MembershipUseCase) GetOrgsAndRBACInfoForUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, RBACScopes, error) { ctx, span := otelx.Start(ctx, membershipTracer, "MembershipUseCase.GetOrgsAndRBACInfoForUser") defer span.End() @@ -463,21 +499,37 @@ func (uc *MembershipUseCase) GetOrgsAndRBACInfoForUser(ctx context.Context, user } userOrgs := make([]uuid.UUID, 0) - // This map holds the list of project IDs by org with RBAC active (user is org "member") - projectIDs := make(map[uuid.UUID][]uuid.UUID) + scopes := make(RBACScopes) for _, m := range memberships { - if m.ResourceType == authz.ResourceTypeOrganization { - userOrgs = append(userOrgs, m.ResourceID) - // If the role in the org is member, we must enable RBAC for projects. - if m.Role.RBACEnabled() { - // get the list of projects in org, and match it with the memberships to build a filter. - // note that appending an empty slice to a nil slice doesn't change it (it's still nil) - projectIDs[m.ResourceID] = getProjectsWithMembershipInOrg(m.ResourceID, memberships) + if m.ResourceType != authz.ResourceTypeOrganization { + continue + } + + userOrgs = append(userOrgs, m.ResourceID) + // If the role in the org is member, we must enable RBAC for its resources: the in-org + // resources are matched against the memberships to build a filter. + if m.Role.RBACEnabled() { + scopes[m.ResourceID] = RBACScope{ + ProjectIDs: resourcesWithMembershipInOrg(m.ResourceID, memberships, authz.ResourceTypeProject), + ProductIDs: resourcesWithMembershipInOrg(m.ResourceID, memberships, authz.ResourceTypeProduct), } } } - return userOrgs, projectIDs, nil + return userOrgs, scopes, nil +} + +// resourcesWithMembershipInOrg returns the IDs of the resources of the given type in the org for +// which the subject holds a membership. +func resourcesWithMembershipInOrg(orgID uuid.UUID, memberships []*Membership, resourceType authz.ResourceType) []uuid.UUID { + ids := make([]uuid.UUID, 0) + for _, m := range memberships { + if m.ResourceType == resourceType && m.OrganizationID == orgID { + ids = append(ids, m.ResourceID) + } + } + + return ids } // isUserSoleOwner checks if the user is the only owner in the organization diff --git a/app/controlplane/pkg/biz/mocks/CASMappingRepo.go b/app/controlplane/pkg/biz/mocks/CASMappingRepo.go index 6b36c85f9..59dfa226b 100644 --- a/app/controlplane/pkg/biz/mocks/CASMappingRepo.go +++ b/app/controlplane/pkg/biz/mocks/CASMappingRepo.go @@ -120,8 +120,8 @@ func (_c *CASMappingRepo_Create_Call) RunAndReturn(run func(ctx context.Context, } // FindByDigestInOrgs provides a mock function for the type CASMappingRepo -func (_mock *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (*biz.CASMapping, error) { - ret := _mock.Called(ctx, digest, orgs, projectIDs) +func (_mock *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, scopes biz.RBACScopes) (*biz.CASMapping, error) { + ret := _mock.Called(ctx, digest, orgs, scopes) if len(ret) == 0 { panic("no return value specified for FindByDigestInOrgs") @@ -129,18 +129,18 @@ func (_mock *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest stri var r0 *biz.CASMapping var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, []uuid.UUID, map[uuid.UUID][]uuid.UUID) (*biz.CASMapping, error)); ok { - return returnFunc(ctx, digest, orgs, projectIDs) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []uuid.UUID, biz.RBACScopes) (*biz.CASMapping, error)); ok { + return returnFunc(ctx, digest, orgs, scopes) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, []uuid.UUID, map[uuid.UUID][]uuid.UUID) *biz.CASMapping); ok { - r0 = returnFunc(ctx, digest, orgs, projectIDs) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []uuid.UUID, biz.RBACScopes) *biz.CASMapping); ok { + r0 = returnFunc(ctx, digest, orgs, scopes) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.CASMapping) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, []uuid.UUID, map[uuid.UUID][]uuid.UUID) error); ok { - r1 = returnFunc(ctx, digest, orgs, projectIDs) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, []uuid.UUID, biz.RBACScopes) error); ok { + r1 = returnFunc(ctx, digest, orgs, scopes) } else { r1 = ret.Error(1) } @@ -156,12 +156,12 @@ type CASMappingRepo_FindByDigestInOrgs_Call struct { // - ctx context.Context // - digest string // - orgs []uuid.UUID -// - projectIDs map[uuid.UUID][]uuid.UUID -func (_e *CASMappingRepo_Expecter) FindByDigestInOrgs(ctx interface{}, digest interface{}, orgs interface{}, projectIDs interface{}) *CASMappingRepo_FindByDigestInOrgs_Call { - return &CASMappingRepo_FindByDigestInOrgs_Call{Call: _e.mock.On("FindByDigestInOrgs", ctx, digest, orgs, projectIDs)} +// - scopes biz.RBACScopes +func (_e *CASMappingRepo_Expecter) FindByDigestInOrgs(ctx interface{}, digest interface{}, orgs interface{}, scopes interface{}) *CASMappingRepo_FindByDigestInOrgs_Call { + return &CASMappingRepo_FindByDigestInOrgs_Call{Call: _e.mock.On("FindByDigestInOrgs", ctx, digest, orgs, scopes)} } -func (_c *CASMappingRepo_FindByDigestInOrgs_Call) Run(run func(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID)) *CASMappingRepo_FindByDigestInOrgs_Call { +func (_c *CASMappingRepo_FindByDigestInOrgs_Call) Run(run func(ctx context.Context, digest string, orgs []uuid.UUID, scopes biz.RBACScopes)) *CASMappingRepo_FindByDigestInOrgs_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -175,9 +175,9 @@ func (_c *CASMappingRepo_FindByDigestInOrgs_Call) Run(run func(ctx context.Conte if args[2] != nil { arg2 = args[2].([]uuid.UUID) } - var arg3 map[uuid.UUID][]uuid.UUID + var arg3 biz.RBACScopes if args[3] != nil { - arg3 = args[3].(map[uuid.UUID][]uuid.UUID) + arg3 = args[3].(biz.RBACScopes) } run( arg0, @@ -194,7 +194,81 @@ func (_c *CASMappingRepo_FindByDigestInOrgs_Call) Return(cASMapping *biz.CASMapp return _c } -func (_c *CASMappingRepo_FindByDigestInOrgs_Call) RunAndReturn(run func(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (*biz.CASMapping, error)) *CASMappingRepo_FindByDigestInOrgs_Call { +func (_c *CASMappingRepo_FindByDigestInOrgs_Call) RunAndReturn(run func(ctx context.Context, digest string, orgs []uuid.UUID, scopes biz.RBACScopes) (*biz.CASMapping, error)) *CASMappingRepo_FindByDigestInOrgs_Call { + _c.Call.Return(run) + return _c +} + +// ListByDigestInOrg provides a mock function for the type CASMappingRepo +func (_mock *CASMappingRepo) ListByDigestInOrg(ctx context.Context, digest string, orgID uuid.UUID) ([]*biz.CASMapping, error) { + ret := _mock.Called(ctx, digest, orgID) + + if len(ret) == 0 { + panic("no return value specified for ListByDigestInOrg") + } + + var r0 []*biz.CASMapping + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uuid.UUID) ([]*biz.CASMapping, error)); ok { + return returnFunc(ctx, digest, orgID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uuid.UUID) []*biz.CASMapping); ok { + r0 = returnFunc(ctx, digest, orgID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*biz.CASMapping) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, uuid.UUID) error); ok { + r1 = returnFunc(ctx, digest, orgID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CASMappingRepo_ListByDigestInOrg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListByDigestInOrg' +type CASMappingRepo_ListByDigestInOrg_Call struct { + *mock.Call +} + +// ListByDigestInOrg is a helper method to define mock.On call +// - ctx context.Context +// - digest string +// - orgID uuid.UUID +func (_e *CASMappingRepo_Expecter) ListByDigestInOrg(ctx interface{}, digest interface{}, orgID interface{}) *CASMappingRepo_ListByDigestInOrg_Call { + return &CASMappingRepo_ListByDigestInOrg_Call{Call: _e.mock.On("ListByDigestInOrg", ctx, digest, orgID)} +} + +func (_c *CASMappingRepo_ListByDigestInOrg_Call) Run(run func(ctx context.Context, digest string, orgID uuid.UUID)) *CASMappingRepo_ListByDigestInOrg_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 uuid.UUID + if args[2] != nil { + arg2 = args[2].(uuid.UUID) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CASMappingRepo_ListByDigestInOrg_Call) Return(cASMappings []*biz.CASMapping, err error) *CASMappingRepo_ListByDigestInOrg_Call { + _c.Call.Return(cASMappings, err) + return _c +} + +func (_c *CASMappingRepo_ListByDigestInOrg_Call) RunAndReturn(run func(ctx context.Context, digest string, orgID uuid.UUID) ([]*biz.CASMapping, error)) *CASMappingRepo_ListByDigestInOrg_Call { _c.Call.Return(run) return _c } diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index fad8f8ad8..24f2dea1b 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -715,18 +715,6 @@ func (uc *ProjectUseCase) verifyRequesterHasPermissions(ctx context.Context, org return nil } -// getProjectsWithMembership returns the list of project IDs in the org for which the user has a membership -func getProjectsWithMembershipInOrg(orgID uuid.UUID, memberships []*Membership) []uuid.UUID { - ids := make([]uuid.UUID, 0) - for _, m := range memberships { - if m.ResourceType == authz.ResourceTypeProject && m.OrganizationID == orgID { - ids = append(ids, m.ResourceID) - } - } - - return ids -} - // UpdateMemberRole updates the role of a user or group in a project. func (uc *ProjectUseCase) UpdateMemberRole(ctx context.Context, orgID uuid.UUID, opts *UpdateMemberRoleOpts) error { ctx, span := otelx.Start(ctx, projectTracer, "ProjectUseCase.UpdateMemberRole") diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index c607d7e9e..06bbfb019 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -172,14 +172,15 @@ func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, return nil, "", NewErrInvalidUUID(err) } - userOrgs, projectIDs, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) + userOrgs, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) if err != nil { return nil, "", err } // We pass the list of organizationsIDs from where to look for the referrer: - // the organizations the user is a member of. - return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs, p, extraFilters...) + // the organizations the user is a member of. Referrers are always project-scoped, so only the + // project dimension of the RBAC scopes is relevant here. + return s.GetFromRoot(ctx, digest, rootKind, userOrgs, scopes.ProjectIDsByOrg(), p, extraFilters...) } func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, p *pagination.CursorOptions, extraFilters ...GetFromRootFilter) (*StoredReferrer, string, error) { diff --git a/app/controlplane/pkg/data/casmapping.go b/app/controlplane/pkg/data/casmapping.go index cbc7d0add..14e4a2528 100644 --- a/app/controlplane/pkg/data/casmapping.go +++ b/app/controlplane/pkg/data/casmapping.go @@ -94,7 +94,9 @@ func (r *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID SetOrganizationID(casBackend.OrganizationID) if opts != nil { - query.SetNillableProjectID(opts.ProjectID).SetNillableWorkflowRunID(opts.WorkflowRunID) + query.SetNillableProjectID(opts.ProjectID). + SetNillableProductID(opts.ProductID). + SetNillableWorkflowRunID(opts.WorkflowRunID) } mapping, err := query.Save(ctx) @@ -107,13 +109,13 @@ func (r *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID } // FindByDigestInOrgs returns a single CAS mapping for the digest that is reachable through one of -// the given organizations, honouring project-level RBAC when projectIDs is provided for an org. The +// the given organizations, honouring resource-level RBAC when a scope is provided for an org. The // mapping stored in the default backend is preferred; ties break on the oldest mapping for a stable // result. It returns (nil, nil) when no accessible mapping exists. // // The selection is performed entirely in the database with a LIMIT 1, so the cost is independent of // how many mappings a digest accumulates (e.g. the same artifact pushed across thousands of runs). -func (r *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (*biz.CASMapping, error) { +func (r *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, scopes biz.RBACScopes) (*biz.CASMapping, error) { ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.FindByDigestInOrgs") defer span.End() @@ -122,18 +124,25 @@ func (r *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, } // Build an OR of per-org predicates. When an org has RBAC enabled (its key is present in - // projectIDs) the mapping's project must be one of the visible projects; otherwise the whole org - // is accessible. + // scopes) the mapping must be scoped to a project or a product the subject can see; otherwise + // the whole org is accessible. orgPreds := make([]predicate.CASMapping, 0, len(orgs)) for _, o := range orgs { - if visibleProjects, ok := projectIDs[o]; ok { - orgPreds = append(orgPreds, casmapping.And( - casmapping.OrganizationID(o), - casmapping.ProjectIDIn(visibleProjects...), - )) - } else { + scope, rbacEnabled := scopes[o] + if !rbacEnabled { orgPreds = append(orgPreds, casmapping.OrganizationID(o)) + continue } + + // A subject with no visible resources must match nothing: ent renders an IN with no + // arguments as FALSE, so an empty scope yields "FALSE OR FALSE". + orgPreds = append(orgPreds, casmapping.And( + casmapping.OrganizationID(o), + casmapping.Or( + casmapping.ProjectIDIn(scope.ProjectIDs...), + casmapping.ProductIDIn(scope.ProductIDs...), + ), + )) } m, err := r.findOnePreferringDefault(ctx, casmapping.Digest(digest), casmapping.Or(orgPreds...)) @@ -144,6 +153,43 @@ func (r *CASMappingRepo) FindByDigestInOrgs(ctx context.Context, digest string, return entCASMappingToBiz(m) } +// ListByDigestInOrg returns every CAS mapping for the digest within the given organization, with no +// RBAC filtering applied. Mappings pointing to a (soft) deleted backend are left out, as they can +// no longer serve the artifact. +func (r *CASMappingRepo) ListByDigestInOrg(ctx context.Context, digest string, orgID uuid.UUID) ([]*biz.CASMapping, error) { + ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.ListByDigestInOrg") + defer span.End() + + mappings, err := r.queryServiceable(casmapping.Digest(digest), casmapping.OrganizationID(orgID)). + Order(casmapping.ByCreatedAt(sql.OrderAsc())). + All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query cas mappings by digest: %w", err) + } + + result := make([]*biz.CASMapping, 0, len(mappings)) + for _, m := range mappings { + bizMapping, err := entCASMappingToBiz(m) + if err != nil { + return nil, err + } + result = append(result, bizMapping) + } + + return result, nil +} + +// queryServiceable narrows a CAS mapping query down to the mappings matching the given predicates +// that can still serve their artifact, with the backend eager-loaded so the result can be mapped to +// the biz layer. +func (r *CASMappingRepo) queryServiceable(preds ...predicate.CASMapping) *ent.CASMappingQuery { + return r.data.DB.CASMapping.Query(). + Where(preds...). + // Never return a mapping whose backend has been (soft) deleted; it cannot serve downloads. + Where(casmapping.HasCasBackendWith(casbackend.DeletedAtIsNil())). + WithCasBackend() +} + // findOnePreferringDefault returns the first CAS mapping matching the given predicates, preferring // the one stored in the default backend and breaking ties on the oldest mapping. It returns // (nil, nil) when nothing matches. @@ -151,15 +197,11 @@ func (r *CASMappingRepo) findOnePreferringDefault(ctx context.Context, preds ... ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.findOnePreferringDefault") defer span.End() - m, err := r.data.DB.CASMapping.Query(). - Where(preds...). - // Never return a mapping whose backend has been (soft) deleted; it cannot serve downloads. - Where(casmapping.HasCasBackendWith(casbackend.DeletedAtIsNil())). + m, err := r.queryServiceable(preds...). Order( casmapping.ByCasBackendField(casbackend.FieldDefault, sql.OrderDesc()), casmapping.ByCreatedAt(sql.OrderAsc()), ). - WithCasBackend(). First(ctx) if err != nil { if ent.IsNotFound(err) { @@ -204,5 +246,6 @@ func entCASMappingToBiz(input *ent.CASMapping) (*biz.CASMapping, error) { OrgID: input.OrganizationID, CreatedAt: toTimePtr(input.CreatedAt), ProjectID: input.ProjectID, + ProductID: input.ProductID, }, nil } diff --git a/app/controlplane/pkg/data/ent/casmapping.go b/app/controlplane/pkg/data/ent/casmapping.go index cc5b2e253..32716b2a3 100644 --- a/app/controlplane/pkg/data/ent/casmapping.go +++ b/app/controlplane/pkg/data/ent/casmapping.go @@ -31,6 +31,8 @@ type CASMapping struct { OrganizationID uuid.UUID `json:"organization_id,omitempty"` // ProjectID holds the value of the "project_id" field. ProjectID uuid.UUID `json:"project_id,omitempty"` + // ProductID holds the value of the "product_id" field. + ProductID uuid.UUID `json:"product_id,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the CASMappingQuery when eager-loading is set. Edges CASMappingEdges `json:"edges"` @@ -93,7 +95,7 @@ func (*CASMapping) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullString) case casmapping.FieldCreatedAt: values[i] = new(sql.NullTime) - case casmapping.FieldID, casmapping.FieldWorkflowRunID, casmapping.FieldOrganizationID, casmapping.FieldProjectID: + case casmapping.FieldID, casmapping.FieldWorkflowRunID, casmapping.FieldOrganizationID, casmapping.FieldProjectID, casmapping.FieldProductID: values[i] = new(uuid.UUID) case casmapping.ForeignKeys[0]: // cas_mapping_cas_backend values[i] = &sql.NullScanner{S: new(uuid.UUID)} @@ -148,6 +150,12 @@ func (_m *CASMapping) assignValues(columns []string, values []any) error { } else if value != nil { _m.ProjectID = *value } + case casmapping.FieldProductID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field product_id", values[i]) + } else if value != nil { + _m.ProductID = *value + } case casmapping.ForeignKeys[0]: if value, ok := values[i].(*sql.NullScanner); !ok { return fmt.Errorf("unexpected type %T for field cas_mapping_cas_backend", values[i]) @@ -220,6 +228,9 @@ func (_m *CASMapping) String() string { builder.WriteString(", ") builder.WriteString("project_id=") builder.WriteString(fmt.Sprintf("%v", _m.ProjectID)) + builder.WriteString(", ") + builder.WriteString("product_id=") + builder.WriteString(fmt.Sprintf("%v", _m.ProductID)) builder.WriteByte(')') return builder.String() } diff --git a/app/controlplane/pkg/data/ent/casmapping/casmapping.go b/app/controlplane/pkg/data/ent/casmapping/casmapping.go index 52131d81e..918673037 100644 --- a/app/controlplane/pkg/data/ent/casmapping/casmapping.go +++ b/app/controlplane/pkg/data/ent/casmapping/casmapping.go @@ -25,6 +25,8 @@ const ( FieldOrganizationID = "organization_id" // FieldProjectID holds the string denoting the project_id field in the database. FieldProjectID = "project_id" + // FieldProductID holds the string denoting the product_id field in the database. + FieldProductID = "product_id" // EdgeCasBackend holds the string denoting the cas_backend edge name in mutations. EdgeCasBackend = "cas_backend" // EdgeOrganization holds the string denoting the organization edge name in mutations. @@ -64,6 +66,7 @@ var Columns = []string{ FieldWorkflowRunID, FieldOrganizationID, FieldProjectID, + FieldProductID, } // ForeignKeys holds the SQL foreign-keys that are owned by the "cas_mappings" @@ -127,6 +130,11 @@ func ByProjectID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProjectID, opts...).ToFunc() } +// ByProductID orders the results by the product_id field. +func ByProductID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProductID, opts...).ToFunc() +} + // ByCasBackendField orders the results by cas_backend field. func ByCasBackendField(field string, opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/casmapping/where.go b/app/controlplane/pkg/data/ent/casmapping/where.go index ca6c96a58..2f851cb42 100644 --- a/app/controlplane/pkg/data/ent/casmapping/where.go +++ b/app/controlplane/pkg/data/ent/casmapping/where.go @@ -81,6 +81,11 @@ func ProjectID(v uuid.UUID) predicate.CASMapping { return predicate.CASMapping(sql.FieldEQ(FieldProjectID, v)) } +// ProductID applies equality check predicate on the "product_id" field. It's identical to ProductIDEQ. +func ProductID(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldEQ(FieldProductID, v)) +} + // DigestEQ applies the EQ predicate on the "digest" field. func DigestEQ(v string) predicate.CASMapping { return predicate.CASMapping(sql.FieldEQ(FieldDigest, v)) @@ -286,6 +291,56 @@ func ProjectIDNotNil() predicate.CASMapping { return predicate.CASMapping(sql.FieldNotNull(FieldProjectID)) } +// ProductIDEQ applies the EQ predicate on the "product_id" field. +func ProductIDEQ(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldEQ(FieldProductID, v)) +} + +// ProductIDNEQ applies the NEQ predicate on the "product_id" field. +func ProductIDNEQ(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldNEQ(FieldProductID, v)) +} + +// ProductIDIn applies the In predicate on the "product_id" field. +func ProductIDIn(vs ...uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldIn(FieldProductID, vs...)) +} + +// ProductIDNotIn applies the NotIn predicate on the "product_id" field. +func ProductIDNotIn(vs ...uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldNotIn(FieldProductID, vs...)) +} + +// ProductIDGT applies the GT predicate on the "product_id" field. +func ProductIDGT(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldGT(FieldProductID, v)) +} + +// ProductIDGTE applies the GTE predicate on the "product_id" field. +func ProductIDGTE(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldGTE(FieldProductID, v)) +} + +// ProductIDLT applies the LT predicate on the "product_id" field. +func ProductIDLT(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldLT(FieldProductID, v)) +} + +// ProductIDLTE applies the LTE predicate on the "product_id" field. +func ProductIDLTE(v uuid.UUID) predicate.CASMapping { + return predicate.CASMapping(sql.FieldLTE(FieldProductID, v)) +} + +// ProductIDIsNil applies the IsNil predicate on the "product_id" field. +func ProductIDIsNil() predicate.CASMapping { + return predicate.CASMapping(sql.FieldIsNull(FieldProductID)) +} + +// ProductIDNotNil applies the NotNil predicate on the "product_id" field. +func ProductIDNotNil() predicate.CASMapping { + return predicate.CASMapping(sql.FieldNotNull(FieldProductID)) +} + // HasCasBackend applies the HasEdge predicate on the "cas_backend" edge. func HasCasBackend() predicate.CASMapping { return predicate.CASMapping(func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/casmapping_create.go b/app/controlplane/pkg/data/ent/casmapping_create.go index f58c68657..35bf42cc5 100644 --- a/app/controlplane/pkg/data/ent/casmapping_create.go +++ b/app/controlplane/pkg/data/ent/casmapping_create.go @@ -81,6 +81,20 @@ func (_c *CASMappingCreate) SetNillableProjectID(v *uuid.UUID) *CASMappingCreate return _c } +// SetProductID sets the "product_id" field. +func (_c *CASMappingCreate) SetProductID(v uuid.UUID) *CASMappingCreate { + _c.mutation.SetProductID(v) + return _c +} + +// SetNillableProductID sets the "product_id" field if the given value is not nil. +func (_c *CASMappingCreate) SetNillableProductID(v *uuid.UUID) *CASMappingCreate { + if v != nil { + _c.SetProductID(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *CASMappingCreate) SetID(v uuid.UUID) *CASMappingCreate { _c.mutation.SetID(v) @@ -226,6 +240,10 @@ func (_c *CASMappingCreate) createSpec() (*CASMapping, *sqlgraph.CreateSpec) { _spec.SetField(casmapping.FieldWorkflowRunID, field.TypeUUID, value) _node.WorkflowRunID = value } + if value, ok := _c.mutation.ProductID(); ok { + _spec.SetField(casmapping.FieldProductID, field.TypeUUID, value) + _node.ProductID = value + } if nodes := _c.mutation.CasBackendIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -361,6 +379,9 @@ func (u *CASMappingUpsertOne) UpdateNewValues() *CASMappingUpsertOne { if _, exists := u.create.mutation.ProjectID(); exists { s.SetIgnore(casmapping.FieldProjectID) } + if _, exists := u.create.mutation.ProductID(); exists { + s.SetIgnore(casmapping.FieldProductID) + } })) return u } @@ -590,6 +611,9 @@ func (u *CASMappingUpsertBulk) UpdateNewValues() *CASMappingUpsertBulk { if _, exists := b.mutation.ProjectID(); exists { s.SetIgnore(casmapping.FieldProjectID) } + if _, exists := b.mutation.ProductID(); exists { + s.SetIgnore(casmapping.FieldProductID) + } } })) return u diff --git a/app/controlplane/pkg/data/ent/casmapping_update.go b/app/controlplane/pkg/data/ent/casmapping_update.go index 5d0e59a97..3edeb408a 100644 --- a/app/controlplane/pkg/data/ent/casmapping_update.go +++ b/app/controlplane/pkg/data/ent/casmapping_update.go @@ -92,6 +92,9 @@ func (_u *CASMappingUpdate) sqlSave(ctx context.Context) (_node int, err error) if _u.mutation.WorkflowRunIDCleared() { _spec.ClearField(casmapping.FieldWorkflowRunID, field.TypeUUID) } + if _u.mutation.ProductIDCleared() { + _spec.ClearField(casmapping.FieldProductID, field.TypeUUID) + } _spec.AddModifiers(_u.modifiers...) if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { @@ -208,6 +211,9 @@ func (_u *CASMappingUpdateOne) sqlSave(ctx context.Context) (_node *CASMapping, if _u.mutation.WorkflowRunIDCleared() { _spec.ClearField(casmapping.FieldWorkflowRunID, field.TypeUUID) } + if _u.mutation.ProductIDCleared() { + _spec.ClearField(casmapping.FieldProductID, field.TypeUUID) + } _spec.AddModifiers(_u.modifiers...) _node = &CASMapping{config: _u.config} _spec.Assign = _node.assignValues diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260820221508.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260820221508.sql new file mode 100644 index 000000000..b90e277b2 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260820221508.sql @@ -0,0 +1,2 @@ +-- Modify "cas_mappings" table +ALTER TABLE "cas_mappings" ADD COLUMN "product_id" uuid NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index ad4003e0c..17a4cc9bd 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:hBVIjVioi1u2VnBzWIATzfZi0fpDZ189Lam4XUp83Zs= +h1:0gd37KIxD9roNz1eUcnatAAT/0jD8rmvTkXiV/tELO4= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -138,3 +138,4 @@ h1:hBVIjVioi1u2VnBzWIATzfZi0fpDZ189Lam4XUp83Zs= 20260527093110.sql h1:Jgq9xDyLakqIVMo3LZF4pPYAkBSc2G5qUK/IV9bzYc4= 20260608210839.sql h1:RfwH7Yf8FRzqPdJeNzfIVH5TwPEush04KMAv4K1c2zY= 20260609111546.sql h1:2NQIGvPRGNb0XeCbokCSZ8CyuiuIhgbXix9XUWJok2M= +20260820221508.sql h1:avp0CjGxQsDVL9TfTisZh0A8sIQHk2awXiz432ozhQI= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 920f1fb87..52e7c0f1c 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -147,6 +147,7 @@ var ( {Name: "digest", Type: field.TypeString}, {Name: "created_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"}, {Name: "workflow_run_id", Type: field.TypeUUID, Nullable: true}, + {Name: "product_id", Type: field.TypeUUID, Nullable: true}, {Name: "cas_mapping_cas_backend", Type: field.TypeUUID}, {Name: "organization_id", Type: field.TypeUUID}, {Name: "project_id", Type: field.TypeUUID, Nullable: true}, @@ -159,19 +160,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "cas_mappings_cas_backends_cas_backend", - Columns: []*schema.Column{CasMappingsColumns[4]}, + Columns: []*schema.Column{CasMappingsColumns[5]}, RefColumns: []*schema.Column{CasBackendsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "cas_mappings_organizations_organization", - Columns: []*schema.Column{CasMappingsColumns[5]}, + Columns: []*schema.Column{CasMappingsColumns[6]}, RefColumns: []*schema.Column{OrganizationsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "cas_mappings_projects_project", - Columns: []*schema.Column{CasMappingsColumns[6]}, + Columns: []*schema.Column{CasMappingsColumns[7]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.Cascade, }, @@ -190,7 +191,7 @@ var ( { Name: "casmapping_organization_id", Unique: false, - Columns: []*schema.Column{CasMappingsColumns[5]}, + Columns: []*schema.Column{CasMappingsColumns[6]}, }, }, } diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 072dac02f..60430e57b 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -3153,6 +3153,7 @@ type CASMappingMutation struct { digest *string created_at *time.Time workflow_run_id *uuid.UUID + product_id *uuid.UUID clearedFields map[string]struct{} cas_backend *uuid.UUID clearedcas_backend bool @@ -3475,6 +3476,55 @@ func (m *CASMappingMutation) ResetProjectID() { delete(m.clearedFields, casmapping.FieldProjectID) } +// SetProductID sets the "product_id" field. +func (m *CASMappingMutation) SetProductID(u uuid.UUID) { + m.product_id = &u +} + +// ProductID returns the value of the "product_id" field in the mutation. +func (m *CASMappingMutation) ProductID() (r uuid.UUID, exists bool) { + v := m.product_id + if v == nil { + return + } + return *v, true +} + +// OldProductID returns the old "product_id" field's value of the CASMapping entity. +// If the CASMapping object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *CASMappingMutation) OldProductID(ctx context.Context) (v uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProductID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProductID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProductID: %w", err) + } + return oldValue.ProductID, nil +} + +// ClearProductID clears the value of the "product_id" field. +func (m *CASMappingMutation) ClearProductID() { + m.product_id = nil + m.clearedFields[casmapping.FieldProductID] = struct{}{} +} + +// ProductIDCleared returns if the "product_id" field was cleared in this mutation. +func (m *CASMappingMutation) ProductIDCleared() bool { + _, ok := m.clearedFields[casmapping.FieldProductID] + return ok +} + +// ResetProductID resets all changes to the "product_id" field. +func (m *CASMappingMutation) ResetProductID() { + m.product_id = nil + delete(m.clearedFields, casmapping.FieldProductID) +} + // SetCasBackendID sets the "cas_backend" edge to the CASBackend entity by id. func (m *CASMappingMutation) SetCasBackendID(id uuid.UUID) { m.cas_backend = &id @@ -3602,7 +3652,7 @@ func (m *CASMappingMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *CASMappingMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 6) if m.digest != nil { fields = append(fields, casmapping.FieldDigest) } @@ -3618,6 +3668,9 @@ func (m *CASMappingMutation) Fields() []string { if m.project != nil { fields = append(fields, casmapping.FieldProjectID) } + if m.product_id != nil { + fields = append(fields, casmapping.FieldProductID) + } return fields } @@ -3636,6 +3689,8 @@ func (m *CASMappingMutation) Field(name string) (ent.Value, bool) { return m.OrganizationID() case casmapping.FieldProjectID: return m.ProjectID() + case casmapping.FieldProductID: + return m.ProductID() } return nil, false } @@ -3655,6 +3710,8 @@ func (m *CASMappingMutation) OldField(ctx context.Context, name string) (ent.Val return m.OldOrganizationID(ctx) case casmapping.FieldProjectID: return m.OldProjectID(ctx) + case casmapping.FieldProductID: + return m.OldProductID(ctx) } return nil, fmt.Errorf("unknown CASMapping field %s", name) } @@ -3699,6 +3756,13 @@ func (m *CASMappingMutation) SetField(name string, value ent.Value) error { } m.SetProjectID(v) return nil + case casmapping.FieldProductID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProductID(v) + return nil } return fmt.Errorf("unknown CASMapping field %s", name) } @@ -3735,6 +3799,9 @@ func (m *CASMappingMutation) ClearedFields() []string { if m.FieldCleared(casmapping.FieldProjectID) { fields = append(fields, casmapping.FieldProjectID) } + if m.FieldCleared(casmapping.FieldProductID) { + fields = append(fields, casmapping.FieldProductID) + } return fields } @@ -3755,6 +3822,9 @@ func (m *CASMappingMutation) ClearField(name string) error { case casmapping.FieldProjectID: m.ClearProjectID() return nil + case casmapping.FieldProductID: + m.ClearProductID() + return nil } return fmt.Errorf("unknown CASMapping nullable field %s", name) } @@ -3778,6 +3848,9 @@ func (m *CASMappingMutation) ResetField(name string) error { case casmapping.FieldProjectID: m.ResetProjectID() return nil + case casmapping.FieldProductID: + m.ResetProductID() + return nil } return fmt.Errorf("unknown CASMapping field %s", name) } diff --git a/app/controlplane/pkg/data/ent/schema/casmapping.go b/app/controlplane/pkg/data/ent/schema/casmapping.go index 0484e0094..05887a417 100644 --- a/app/controlplane/pkg/data/ent/schema/casmapping.go +++ b/app/controlplane/pkg/data/ent/schema/casmapping.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -42,6 +42,10 @@ func (CASMapping) Fields() []ent.Field { field.UUID("workflow_run_id", uuid.UUID{}).Immutable().Optional(), field.UUID("organization_id", uuid.UUID{}).Immutable(), field.UUID("project_id", uuid.UUID{}).Immutable().Optional(), + // Product the artifact belongs to, when it is not scoped to a project. Products live in a + // downstream (platform) database, so this is a plain UUID reference with no edge and no + // foreign key, following the workflow_run_id precedent. + field.UUID("product_id", uuid.UUID{}).Immutable().Optional(), } }