From cea14d4044c733985beeadb3757e731c4aab0643 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Sat, 6 Jun 2026 00:08:58 +0200 Subject: [PATCH] fix(controlplane): bound CAS mapping resolution on artifact download Resolving the CAS mapping for a digest could exhaust the request deadline on artifact download. The lookup fetched every mapping for the digest globally and then filtered by org and computed public visibility in memory (1+2N queries). Because the same artifact can be pushed across thousands of workflow runs, a digest accumulates thousands of mappings, while only one is needed to locate the CAS backend. The download path now resolves a single mapping directly in the database with two bounded LIMIT 1 queries: first one reachable through the requester's orgs (organization + project RBAC predicates), and, only when there is no org-level access, a public mapping (matched on its workflow's visibility via a subquery). Both prefer the default backend. Mappings whose backend is soft-deleted, or whose workflow is soft-deleted, are excluded so downloads are never pointed at removed resources. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 78e84359-afc2-4811-932b-a03f056fc52a --- app/controlplane/pkg/biz/.mockery.yml | 1 + app/controlplane/pkg/biz/casmapping.go | 99 ++----- .../pkg/biz/casmapping_integration_test.go | 205 ++++++++------ .../pkg/biz/mocks/CASMappingRepo.go | 256 +++++++++++++++--- app/controlplane/pkg/data/casmapping.go | 117 ++++++-- 5 files changed, 455 insertions(+), 223 deletions(-) diff --git a/app/controlplane/pkg/biz/.mockery.yml b/app/controlplane/pkg/biz/.mockery.yml index 528d4cf71..93dda23d7 100644 --- a/app/controlplane/pkg/biz/.mockery.yml +++ b/app/controlplane/pkg/biz/.mockery.yml @@ -16,5 +16,6 @@ packages: interfaces: APITokenRepo: CASBackendRepo: + CASMappingRepo: OrganizationRepo: WorkflowRunRepo: diff --git a/app/controlplane/pkg/biz/casmapping.go b/app/controlplane/pkg/biz/casmapping.go index 712595e53..d2d76c0d7 100644 --- a/app/controlplane/pkg/biz/casmapping.go +++ b/app/controlplane/pkg/biz/casmapping.go @@ -18,7 +18,6 @@ package biz import ( "context" "fmt" - "slices" "time" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" @@ -50,8 +49,12 @@ type CASMappingFindOptions struct { 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) - // List all the CAS mappings for the given digest - FindByDigest(ctx context.Context, digest string) ([]*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) + // FindPublicByDigest returns a single public mapping for the digest, preferring the default + // backend. Returns (nil, nil) when no public mapping exists. + FindPublicByDigest(ctx context.Context, digest string) (*CASMapping, error) } type CASMappingUseCase struct { @@ -87,13 +90,6 @@ func (uc *CASMappingUseCase) Create(ctx context.Context, digest string, casBacke return uc.repo.Create(ctx, digest, casBackendUUID, opts) } -func (uc *CASMappingUseCase) FindByDigest(ctx context.Context, digest string) ([]*CASMapping, error) { - ctx, span := otelx.Start(ctx, casMappingTracer, "CASMappingUseCase.FindByDigest") - defer span.End() - - return uc.repo.FindByDigest(ctx, digest) -} - // FindCASMappingForDownloadByUser returns the CASMapping appropriate for the given digest and user. // This means, in order: // 1 - Any mapping that points to an organization which the user is member of. @@ -146,84 +142,27 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, return nil, NewErrValidationStr("no organizations provided") } - // 1 - All CAS mappings for the given digest - mappings, err := uc.repo.FindByDigest(ctx, digest) + // 1 - A mapping reachable through one of the user's orgs (honouring project RBAC), 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) if err != nil { - return nil, fmt.Errorf("failed to list cas mappings: %w", err) - } - - uc.logger.Debugw("msg", fmt.Sprintf("found %d entries globally", len(mappings)), "digest", digest, "orgs", orgs) - if len(mappings) == 0 { - return nil, NewErrNotFound("digest not found in any mapping") + return nil, fmt.Errorf("failed to find cas mapping in orgs: %w", err) + } else if mapping != nil { + return mapping, nil } - // 2 - CAS mappings associated with the given list of orgs and project IDs - orgMappings, err := filterByOrgs(mappings, orgs, projectIDs) + // 2 - Otherwise, fall back to a public mapping. This only runs when the requester has no + // org-level access to the digest. + mapping, err = uc.repo.FindPublicByDigest(ctx, digest) if err != nil { - return nil, fmt.Errorf("failed to load mappings associated to an user: %w", err) - } else if len(orgMappings) > 0 { - return defaultOrFirst(orgMappings), nil - } - - // 3 - mappings that are public - publicMappings := filterByPublic(mappings) - // The user has not access to neither proprietary nor public mappings - if len(publicMappings) == 0 { + return nil, fmt.Errorf("failed to find public cas mapping: %w", err) + } else if mapping == nil { uc.logger.Warnw("msg", "digest exist but user does not have access to it", "digest", digest, "orgs", orgs) return nil, NewErrNotFound("digest not found in any mapping") } - // Pick the appropriate mapping from multiple ones - return defaultOrFirst(publicMappings), nil -} - -// Extract only the mappings associated with a list of orgs and optionally a list of projects -func filterByOrgs(mappings []*CASMapping, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) ([]*CASMapping, error) { - result := make([]*CASMapping, 0) - - for _, mapping := range mappings { - for _, o := range orgs { - if mapping.OrgID == o { - if visibleProjects, ok := projectIDs[mapping.OrgID]; ok { - if slices.Contains(visibleProjects, mapping.ProjectID) { - result = append(result, mapping) - } - } else { - result = append(result, mapping) - } - } - } - } - - return result, nil -} - -func filterByPublic(mappings []*CASMapping) []*CASMapping { - result := make([]*CASMapping, 0) - - for _, mapping := range mappings { - if mapping.Public { - result = append(result, mapping) - } - } - - return result -} - -func defaultOrFirst(mappings []*CASMapping) *CASMapping { - if len(mappings) == 0 { - return nil - } - - result := mappings[0] - for _, mapping := range mappings { - if mapping.CASBackend.Default { - result = mapping - break - } - } - - return result + return mapping, nil } type CASMappingLookupRef struct { diff --git a/app/controlplane/pkg/biz/casmapping_integration_test.go b/app/controlplane/pkg/biz/casmapping_integration_test.go index e824a0c38..42a5ccb74 100644 --- a/app/controlplane/pkg/biz/casmapping_integration_test.go +++ b/app/controlplane/pkg/biz/casmapping_integration_test.go @@ -158,89 +158,131 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadByOrg() { }) } -func (s *casMappingIntegrationSuite) TestFindByDigest() { - // 1. Digest: validDigest, CASBackend: casBackend1, WorkflowRunID: workflowRun - // 2. Digest: validDigest2, CASBackend: casBackend1, WorkflowRunID: workflowRun - // 3. Digest: validDigest, CASBackend: casBackend2, WorkflowRunID: workflowRun - // 4. Digest: validDigest, CASBackend: casBackend3, WorkflowRunID: publicWorkflowRun - _, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) - require.NoError(s.T(), err) - _, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) - require.NoError(s.T(), err) - _, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) +// When a digest is reachable through several CAS backends, the download lookup must return the +// mapping stored in the default backend, regardless of the order the mappings were created in. +// This locks in the defaultOrFirst behaviour for both the org-scoped and the public fallback paths. +func (s *casMappingIntegrationSuite) TestCASMappingForDownloadPrefersDefaultBackend() { + ctx := context.Background() + + // org1 already has casBackend1 as its default backend. Add a second, non-default backend. + nonDefaultBackend, err := s.CASBackend.Create(ctx, s.org1.ID, randomName(), "my-location", "non-default backend", backendType, nil, false, false, nil) require.NoError(s.T(), err) - _, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID}) + s.Require().False(nonDefaultBackend.Default) + + s.Run("org download returns the default backend even when it is mapped last", func() { + // Map the digest to the non-default backend FIRST, then to the default one. + _, err := s.CASMapping.Create(ctx, validDigest, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) + require.NoError(s.T(), err) + _, err = s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) + require.NoError(s.T(), err) + + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil) + s.NoError(err) + s.Require().NotNil(mapping) + s.Equal(s.casBackend1.ID, mapping.CASBackend.ID) + }) + + s.Run("org download returns the non-default backend when no default mapping exists", func() { + _, err := s.CASMapping.Create(ctx, validDigest2, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) + require.NoError(s.T(), err) + + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil) + s.NoError(err) + s.Require().NotNil(mapping) + s.Equal(nonDefaultBackend.ID, mapping.CASBackend.ID) + }) + + s.Run("public download returns the default backend even when it is mapped last", func() { + // Public mappings (workflow is public) across two backends, non-default created first. + _, err := s.CASMapping.Create(ctx, validDigestPublic, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID}) + require.NoError(s.T(), err) + _, err = s.CASMapping.Create(ctx, validDigestPublic, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID}) + require.NoError(s.T(), err) + + // A requester with no access to org1 falls back to the public mappings. + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestPublic, []uuid.UUID{uuid.New()}, nil) + s.NoError(err) + s.Require().NotNil(mapping) + s.Equal(s.casBackend1.ID, mapping.CASBackend.ID) + }) +} + +// 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. +func (s *casMappingIntegrationSuite) TestCASMappingForDownloadRBAC() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org1.ID) + + // A mapping in org1 scoped to a specific project. + _, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{ + WorkflowRunID: &s.workflowRun.ID, + ProjectID: &s.projectID, + }) require.NoError(s.T(), err) - testcases := []struct { - name string - digest string - want []*biz.CASMapping - wantErr bool - }{ - { - name: "validDigest", - digest: validDigest, - want: []*biz.CASMapping{ - { - Digest: validDigest, - CASBackend: &biz.CASBackend{ID: s.casBackend1.ID}, - WorkflowRunID: s.workflowRun.ID, - OrgID: s.casBackend1.OrganizationID, - Public: false, - }, - { - Digest: validDigest, - CASBackend: &biz.CASBackend{ID: s.casBackend2.ID}, - WorkflowRunID: s.workflowRun.ID, - OrgID: s.casBackend2.OrganizationID, - Public: false, - }, - { - Digest: validDigest, - CASBackend: &biz.CASBackend{ID: s.casBackend3.ID}, - WorkflowRunID: s.publicWorkflowRun.ID, - OrgID: s.casBackend3.OrganizationID, - Public: true, - }, - }, - }, - { - name: "validDigest2", - digest: validDigest2, - want: []*biz.CASMapping{ - { - Digest: validDigest2, - CASBackend: &biz.CASBackend{ID: s.casBackend1.ID}, - WorkflowRunID: s.workflowRun.ID, - OrgID: s.casBackend1.OrganizationID, - Public: false, - }, - }, - }, - { - name: "invalidDigest", - digest: invalidDigest, - want: []*biz.CASMapping{}, - }, - } + 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}}) + s.NoError(err) + s.Require().NotNil(mapping) + s.Equal(s.casBackend1.ID, mapping.CASBackend.ID) + }) - for _, tc := range testcases { - s.Run(tc.name, func() { - got, err := s.CASMapping.FindByDigest(context.Background(), tc.digest) - if tc.wantErr { - s.Error(err) - } else { - s.NoError(err) - if diff := cmp.Diff(tc.want, got, - cmpopts.IgnoreFields(biz.CASMapping{}, "CreatedAt", "ID"), - cmpopts.IgnoreTypes(biz.CASBackend{}), - ); diff != "" { - assert.Failf(s.T(), "mismatch (-want +got):\n%s", diff) - } - } - }) - } + 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.Error(err) + s.Nil(mapping) + }) + + 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.Error(err) + s.Nil(mapping) + }) +} + +// Mappings pointing to a soft-deleted backend, or produced by a soft-deleted workflow, must not be +// served for download. +func (s *casMappingIntegrationSuite) TestCASMappingForDownloadSkipsSoftDeleted() { + ctx := context.Background() + + s.Run("org download skips a mapping whose backend is soft-deleted", func() { + backend, err := s.CASBackend.Create(ctx, s.org1.ID, randomName(), "my-location", "to be deleted", backendType, nil, false, false, nil) + require.NoError(s.T(), err) + _, err = s.CASMapping.Create(ctx, validDigest3, backend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID}) + require.NoError(s.T(), err) + + // Reachable before the backend is deleted. + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest3, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil) + s.NoError(err) + s.Require().NotNil(mapping) + + require.NoError(s.T(), s.CASBackend.SoftDelete(ctx, s.org1.ID, backend.ID.String())) + + // The only mapping points to a deleted backend, so it is no longer served. + mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest3, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil) + s.Error(err) + s.Nil(mapping) + }) + + s.Run("public download skips a mapping whose workflow is soft-deleted", func() { + _, err := s.CASMapping.Create(ctx, validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID}) + require.NoError(s.T(), err) + + // A non-member can reach it through the public fallback while the workflow is public. + mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.New()}, nil) + s.NoError(err) + s.Require().NotNil(mapping) + + require.NoError(s.T(), s.Workflow.Delete(ctx, s.org1.ID, s.publicWorkflow.ID.String())) + + // Once the workflow is soft-deleted the mapping is no longer public. + mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.New()}, nil) + s.Error(err) + s.Nil(mapping) + }) } func (s *casMappingIntegrationSuite) TestCreate() { @@ -342,6 +384,8 @@ 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 } @@ -379,8 +423,11 @@ func (s *casMappingIntegrationSuite) SetupTest() { workflow, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "test-workflow", OrgID: s.org1.ID, Project: "test-project"}) assert.NoError(err) + s.projectID = workflow.ProjectID + publicWorkflow, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "test-workflow-public", OrgID: s.org1.ID, Public: true, Project: "test-project"}) assert.NoError(err) + s.publicWorkflow = publicWorkflow // Find contract revision contractVersion, err := s.WorkflowContract.Describe(ctx, s.org1.ID, workflow.ContractID.String(), 0) diff --git a/app/controlplane/pkg/biz/mocks/CASMappingRepo.go b/app/controlplane/pkg/biz/mocks/CASMappingRepo.go index c0b67fb0b..55639e8e3 100644 --- a/app/controlplane/pkg/biz/mocks/CASMappingRepo.go +++ b/app/controlplane/pkg/biz/mocks/CASMappingRepo.go @@ -1,25 +1,47 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify package mocks import ( - context "context" - - biz "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "context" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" mock "github.com/stretchr/testify/mock" - - uuid "github.com/google/uuid" ) +// NewCASMappingRepo creates a new instance of CASMappingRepo. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCASMappingRepo(t interface { + mock.TestingT + Cleanup(func()) +}) *CASMappingRepo { + mock := &CASMappingRepo{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + // CASMappingRepo is an autogenerated mock type for the CASMappingRepo type type CASMappingRepo struct { mock.Mock } -// Create provides a mock function with given fields: ctx, digest, casBackendID, opts -func (_m *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID uuid.UUID, opts *biz.CASMappingCreateOpts) (*biz.CASMapping, error) { - ret := _m.Called(ctx, digest, casBackendID, opts) +type CASMappingRepo_Expecter struct { + mock *mock.Mock +} + +func (_m *CASMappingRepo) EXPECT() *CASMappingRepo_Expecter { + return &CASMappingRepo_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function for the type CASMappingRepo +func (_mock *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID uuid.UUID, opts *biz.CASMappingCreateOpts) (*biz.CASMapping, error) { + ret := _mock.Called(ctx, digest, casBackendID, opts) if len(ret) == 0 { panic("no return value specified for Create") @@ -27,66 +49,220 @@ func (_m *CASMappingRepo) Create(ctx context.Context, digest string, casBackendI var r0 *biz.CASMapping var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) (*biz.CASMapping, error)); ok { - return rf(ctx, digest, casBackendID, opts) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) (*biz.CASMapping, error)); ok { + return returnFunc(ctx, digest, casBackendID, opts) } - if rf, ok := ret.Get(0).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) *biz.CASMapping); ok { - r0 = rf(ctx, digest, casBackendID, opts) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) *biz.CASMapping); ok { + r0 = returnFunc(ctx, digest, casBackendID, opts) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.CASMapping) } } - - if rf, ok := ret.Get(1).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) error); ok { - r1 = rf(ctx, digest, casBackendID, opts) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, uuid.UUID, *biz.CASMappingCreateOpts) error); ok { + r1 = returnFunc(ctx, digest, casBackendID, opts) } else { r1 = ret.Error(1) } - return r0, r1 } -// FindByDigest provides a mock function with given fields: ctx, digest -func (_m *CASMappingRepo) FindByDigest(ctx context.Context, digest string) ([]*biz.CASMapping, error) { - ret := _m.Called(ctx, digest) +// CASMappingRepo_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type CASMappingRepo_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - digest string +// - casBackendID uuid.UUID +// - opts *biz.CASMappingCreateOpts +func (_e *CASMappingRepo_Expecter) Create(ctx interface{}, digest interface{}, casBackendID interface{}, opts interface{}) *CASMappingRepo_Create_Call { + return &CASMappingRepo_Create_Call{Call: _e.mock.On("Create", ctx, digest, casBackendID, opts)} +} + +func (_c *CASMappingRepo_Create_Call) Run(run func(ctx context.Context, digest string, casBackendID uuid.UUID, opts *biz.CASMappingCreateOpts)) *CASMappingRepo_Create_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) + } + var arg3 *biz.CASMappingCreateOpts + if args[3] != nil { + arg3 = args[3].(*biz.CASMappingCreateOpts) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *CASMappingRepo_Create_Call) Return(cASMapping *biz.CASMapping, err error) *CASMappingRepo_Create_Call { + _c.Call.Return(cASMapping, err) + return _c +} + +func (_c *CASMappingRepo_Create_Call) RunAndReturn(run func(ctx context.Context, digest string, casBackendID uuid.UUID, opts *biz.CASMappingCreateOpts) (*biz.CASMapping, error)) *CASMappingRepo_Create_Call { + _c.Call.Return(run) + return _c +} + +// 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) if len(ret) == 0 { - panic("no return value specified for FindByDigest") + panic("no return value specified for FindByDigestInOrgs") } - var r0 []*biz.CASMapping + var r0 *biz.CASMapping var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) ([]*biz.CASMapping, error)); ok { - return rf(ctx, digest) + 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 rf, ok := ret.Get(0).(func(context.Context, string) []*biz.CASMapping); ok { - r0 = rf(ctx, digest) + 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) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*biz.CASMapping) + r0 = ret.Get(0).(*biz.CASMapping) } } - - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, digest) + 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) } else { r1 = ret.Error(1) } + return r0, r1 +} + +// CASMappingRepo_FindByDigestInOrgs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByDigestInOrgs' +type CASMappingRepo_FindByDigestInOrgs_Call struct { + *mock.Call +} + +// FindByDigestInOrgs is a helper method to define mock.On call +// - 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)} +} + +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 { + _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) + } + var arg3 map[uuid.UUID][]uuid.UUID + if args[3] != nil { + arg3 = args[3].(map[uuid.UUID][]uuid.UUID) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *CASMappingRepo_FindByDigestInOrgs_Call) Return(cASMapping *biz.CASMapping, err error) *CASMappingRepo_FindByDigestInOrgs_Call { + _c.Call.Return(cASMapping, err) + 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 { + _c.Call.Return(run) + return _c +} + +// FindPublicByDigest provides a mock function for the type CASMappingRepo +func (_mock *CASMappingRepo) FindPublicByDigest(ctx context.Context, digest string) (*biz.CASMapping, error) { + ret := _mock.Called(ctx, digest) + + if len(ret) == 0 { + panic("no return value specified for FindPublicByDigest") + } + var r0 *biz.CASMapping + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*biz.CASMapping, error)); ok { + return returnFunc(ctx, digest) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *biz.CASMapping); ok { + r0 = returnFunc(ctx, digest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*biz.CASMapping) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, digest) + } else { + r1 = ret.Error(1) + } return r0, r1 } -// NewCASMappingRepo creates a new instance of CASMappingRepo. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewCASMappingRepo(t interface { - mock.TestingT - Cleanup(func()) -}) *CASMappingRepo { - mock := &CASMappingRepo{} - mock.Mock.Test(t) +// CASMappingRepo_FindPublicByDigest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindPublicByDigest' +type CASMappingRepo_FindPublicByDigest_Call struct { + *mock.Call +} - t.Cleanup(func() { mock.AssertExpectations(t) }) +// FindPublicByDigest is a helper method to define mock.On call +// - ctx context.Context +// - digest string +func (_e *CASMappingRepo_Expecter) FindPublicByDigest(ctx interface{}, digest interface{}) *CASMappingRepo_FindPublicByDigest_Call { + return &CASMappingRepo_FindPublicByDigest_Call{Call: _e.mock.On("FindPublicByDigest", ctx, digest)} +} - return mock +func (_c *CASMappingRepo_FindPublicByDigest_Call) Run(run func(ctx context.Context, digest string)) *CASMappingRepo_FindPublicByDigest_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) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *CASMappingRepo_FindPublicByDigest_Call) Return(cASMapping *biz.CASMapping, err error) *CASMappingRepo_FindPublicByDigest_Call { + _c.Call.Return(cASMapping, err) + return _c +} + +func (_c *CASMappingRepo_FindPublicByDigest_Call) RunAndReturn(run func(ctx context.Context, digest string) (*biz.CASMapping, error)) *CASMappingRepo_FindPublicByDigest_Call { + _c.Call.Return(run) + return _c } diff --git a/app/controlplane/pkg/data/casmapping.go b/app/controlplane/pkg/data/casmapping.go index da83d5da9..08ad455bc 100644 --- a/app/controlplane/pkg/data/casmapping.go +++ b/app/controlplane/pkg/data/casmapping.go @@ -19,9 +19,12 @@ import ( "context" "fmt" + "entgo.io/ent/dialect/sql" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/casbackend" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/casmapping" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflow" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflowrun" "github.com/chainloop-dev/chainloop/pkg/otelx" @@ -74,38 +77,104 @@ func (r *CASMappingRepo) Create(ctx context.Context, digest string, casBackendID return r.findByID(ctx, mapping.ID) } -func (r *CASMappingRepo) FindByDigest(ctx context.Context, digest string) ([]*biz.CASMapping, error) { - ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.FindByDigest") +// 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 +// 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) { + ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.FindByDigestInOrgs") defer span.End() - mappings, err := r.data.DB.CASMapping.Query(). - Where(casmapping.Digest(digest)). - WithCasBackend(). - WithOrganization(). - All(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list cas mappings: %w", err) + if len(orgs) == 0 { + return nil, nil } - res := make([]*biz.CASMapping, 0, len(mappings)) - for _, m := range mappings { - public, err := r.IsPublic(ctx, r.data.DB, m.WorkflowRunID) - if err != nil { - if ent.IsNotFound(err) { - continue - } - - return nil, fmt.Errorf("failed to check if workflow is public: %w", err) - } - r, err := entCASMappingToBiz(m, public) - if err != nil { - return nil, fmt.Errorf("failed to convert cas mapping: %w", err) + // 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. + 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 { + orgPreds = append(orgPreds, casmapping.OrganizationID(o)) } + } + + m, err := r.findOnePreferringDefault(ctx, casmapping.Digest(digest), casmapping.Or(orgPreds...)) + if err != nil || m == nil { + return nil, err + } + + // Access is granted through org membership, independent of the workflow's public visibility. + return entCASMappingToBiz(m, false) +} + +// FindPublicByDigest returns a single CAS mapping for the digest that was produced by a public +// workflow, preferring the default backend. It returns (nil, nil) when no public mapping exists. +// +// A public mapping can live in any organization, so visibility is matched on the mapping's workflow +// rather than on org membership. As there is no ent edge from a mapping to its workflow run, the +// match is expressed as a subquery on workflow_run_id. The selection is bounded with a LIMIT 1. +func (r *CASMappingRepo) FindPublicByDigest(ctx context.Context, digest string) (*biz.CASMapping, error) { + ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.FindPublicByDigest") + defer span.End() + + publicWorkflowRun := func(s *sql.Selector) { + wr := sql.Table(workflowrun.Table) + wf := sql.Table(workflow.Table) + s.Where(sql.In( + s.C(casmapping.FieldWorkflowRunID), + sql.Select(wr.C(workflowrun.FieldID)). + From(wr). + Join(wf).On(wr.C(workflowrun.FieldWorkflowID), wf.C(workflow.FieldID)). + // The workflow must be public and not (soft) deleted. + Where(sql.And( + sql.EQ(wf.C(workflow.FieldPublic), true), + sql.IsNull(wf.C(workflow.FieldDeletedAt)), + )), + )) + } + + m, err := r.findOnePreferringDefault(ctx, casmapping.Digest(digest), publicWorkflowRun) + if err != nil || m == nil { + return nil, err + } + + return entCASMappingToBiz(m, true) +} + +// 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. +func (r *CASMappingRepo) findOnePreferringDefault(ctx context.Context, preds ...predicate.CASMapping) (*ent.CASMapping, error) { + ctx, span := otelx.Start(ctx, casMappingRepoTracer, "CASMappingRepo.findOnePreferringDefault") + defer span.End() - res = append(res, r) + 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())). + Order( + casmapping.ByCasBackendField(casbackend.FieldDefault, sql.OrderDesc()), + casmapping.ByCreatedAt(sql.OrderAsc()), + ). + WithCasBackend(). + First(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to find cas mapping: %w", err) } - return res, nil + return m, nil } // FindByID finds a CAS Mapping by ID