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(), } }