Skip to content

Commit 53f50f1

Browse files
authored
fix(controlplane): bound CAS mapping resolution on artifact download (#3168)
1 parent 8331435 commit 53f50f1

5 files changed

Lines changed: 455 additions & 223 deletions

File tree

app/controlplane/pkg/biz/.mockery.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ packages:
1616
interfaces:
1717
APITokenRepo:
1818
CASBackendRepo:
19+
CASMappingRepo:
1920
OrganizationRepo:
2021
WorkflowRunRepo:

app/controlplane/pkg/biz/casmapping.go

Lines changed: 19 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ package biz
1818
import (
1919
"context"
2020
"fmt"
21-
"slices"
2221
"time"
2322

2423
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
@@ -50,8 +49,12 @@ type CASMappingFindOptions struct {
5049
type CASMappingRepo interface {
5150
// Create a mapping with an optional workflow run id
5251
Create(ctx context.Context, digest string, casBackendID uuid.UUID, opts *CASMappingCreateOpts) (*CASMapping, error)
53-
// List all the CAS mappings for the given digest
54-
FindByDigest(ctx context.Context, digest string) ([]*CASMapping, error)
52+
// FindByDigestInOrgs returns a single accessible mapping for the digest within the given orgs
53+
// (honouring project RBAC), preferring the default backend. Returns (nil, nil) when none exists.
54+
FindByDigestInOrgs(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (*CASMapping, error)
55+
// FindPublicByDigest returns a single public mapping for the digest, preferring the default
56+
// backend. Returns (nil, nil) when no public mapping exists.
57+
FindPublicByDigest(ctx context.Context, digest string) (*CASMapping, error)
5558
}
5659

5760
type CASMappingUseCase struct {
@@ -87,13 +90,6 @@ func (uc *CASMappingUseCase) Create(ctx context.Context, digest string, casBacke
8790
return uc.repo.Create(ctx, digest, casBackendUUID, opts)
8891
}
8992

90-
func (uc *CASMappingUseCase) FindByDigest(ctx context.Context, digest string) ([]*CASMapping, error) {
91-
ctx, span := otelx.Start(ctx, casMappingTracer, "CASMappingUseCase.FindByDigest")
92-
defer span.End()
93-
94-
return uc.repo.FindByDigest(ctx, digest)
95-
}
96-
9793
// FindCASMappingForDownloadByUser returns the CASMapping appropriate for the given digest and user.
9894
// This means, in order:
9995
// 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,
146142
return nil, NewErrValidationStr("no organizations provided")
147143
}
148144

149-
// 1 - All CAS mappings for the given digest
150-
mappings, err := uc.repo.FindByDigest(ctx, digest)
145+
// 1 - A mapping reachable through one of the user's orgs (honouring project RBAC), selected and
146+
// bounded in the database. This is the common path and stays cheap regardless of how many
147+
// mappings a digest has accumulated.
148+
mapping, err := uc.repo.FindByDigestInOrgs(ctx, digest, orgs, projectIDs)
151149
if err != nil {
152-
return nil, fmt.Errorf("failed to list cas mappings: %w", err)
153-
}
154-
155-
uc.logger.Debugw("msg", fmt.Sprintf("found %d entries globally", len(mappings)), "digest", digest, "orgs", orgs)
156-
if len(mappings) == 0 {
157-
return nil, NewErrNotFound("digest not found in any mapping")
150+
return nil, fmt.Errorf("failed to find cas mapping in orgs: %w", err)
151+
} else if mapping != nil {
152+
return mapping, nil
158153
}
159154

160-
// 2 - CAS mappings associated with the given list of orgs and project IDs
161-
orgMappings, err := filterByOrgs(mappings, orgs, projectIDs)
155+
// 2 - Otherwise, fall back to a public mapping. This only runs when the requester has no
156+
// org-level access to the digest.
157+
mapping, err = uc.repo.FindPublicByDigest(ctx, digest)
162158
if err != nil {
163-
return nil, fmt.Errorf("failed to load mappings associated to an user: %w", err)
164-
} else if len(orgMappings) > 0 {
165-
return defaultOrFirst(orgMappings), nil
166-
}
167-
168-
// 3 - mappings that are public
169-
publicMappings := filterByPublic(mappings)
170-
// The user has not access to neither proprietary nor public mappings
171-
if len(publicMappings) == 0 {
159+
return nil, fmt.Errorf("failed to find public cas mapping: %w", err)
160+
} else if mapping == nil {
172161
uc.logger.Warnw("msg", "digest exist but user does not have access to it", "digest", digest, "orgs", orgs)
173162
return nil, NewErrNotFound("digest not found in any mapping")
174163
}
175164

176-
// Pick the appropriate mapping from multiple ones
177-
return defaultOrFirst(publicMappings), nil
178-
}
179-
180-
// Extract only the mappings associated with a list of orgs and optionally a list of projects
181-
func filterByOrgs(mappings []*CASMapping, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) ([]*CASMapping, error) {
182-
result := make([]*CASMapping, 0)
183-
184-
for _, mapping := range mappings {
185-
for _, o := range orgs {
186-
if mapping.OrgID == o {
187-
if visibleProjects, ok := projectIDs[mapping.OrgID]; ok {
188-
if slices.Contains(visibleProjects, mapping.ProjectID) {
189-
result = append(result, mapping)
190-
}
191-
} else {
192-
result = append(result, mapping)
193-
}
194-
}
195-
}
196-
}
197-
198-
return result, nil
199-
}
200-
201-
func filterByPublic(mappings []*CASMapping) []*CASMapping {
202-
result := make([]*CASMapping, 0)
203-
204-
for _, mapping := range mappings {
205-
if mapping.Public {
206-
result = append(result, mapping)
207-
}
208-
}
209-
210-
return result
211-
}
212-
213-
func defaultOrFirst(mappings []*CASMapping) *CASMapping {
214-
if len(mappings) == 0 {
215-
return nil
216-
}
217-
218-
result := mappings[0]
219-
for _, mapping := range mappings {
220-
if mapping.CASBackend.Default {
221-
result = mapping
222-
break
223-
}
224-
}
225-
226-
return result
165+
return mapping, nil
227166
}
228167

229168
type CASMappingLookupRef struct {

app/controlplane/pkg/biz/casmapping_integration_test.go

Lines changed: 126 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -158,89 +158,131 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadByOrg() {
158158
})
159159
}
160160

161-
func (s *casMappingIntegrationSuite) TestFindByDigest() {
162-
// 1. Digest: validDigest, CASBackend: casBackend1, WorkflowRunID: workflowRun
163-
// 2. Digest: validDigest2, CASBackend: casBackend1, WorkflowRunID: workflowRun
164-
// 3. Digest: validDigest, CASBackend: casBackend2, WorkflowRunID: workflowRun
165-
// 4. Digest: validDigest, CASBackend: casBackend3, WorkflowRunID: publicWorkflowRun
166-
_, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
167-
require.NoError(s.T(), err)
168-
_, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
169-
require.NoError(s.T(), err)
170-
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
161+
// When a digest is reachable through several CAS backends, the download lookup must return the
162+
// mapping stored in the default backend, regardless of the order the mappings were created in.
163+
// This locks in the defaultOrFirst behaviour for both the org-scoped and the public fallback paths.
164+
func (s *casMappingIntegrationSuite) TestCASMappingForDownloadPrefersDefaultBackend() {
165+
ctx := context.Background()
166+
167+
// org1 already has casBackend1 as its default backend. Add a second, non-default backend.
168+
nonDefaultBackend, err := s.CASBackend.Create(ctx, s.org1.ID, randomName(), "my-location", "non-default backend", backendType, nil, false, false, nil)
171169
require.NoError(s.T(), err)
172-
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
170+
s.Require().False(nonDefaultBackend.Default)
171+
172+
s.Run("org download returns the default backend even when it is mapped last", func() {
173+
// Map the digest to the non-default backend FIRST, then to the default one.
174+
_, err := s.CASMapping.Create(ctx, validDigest, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
175+
require.NoError(s.T(), err)
176+
_, err = s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
177+
require.NoError(s.T(), err)
178+
179+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
180+
s.NoError(err)
181+
s.Require().NotNil(mapping)
182+
s.Equal(s.casBackend1.ID, mapping.CASBackend.ID)
183+
})
184+
185+
s.Run("org download returns the non-default backend when no default mapping exists", func() {
186+
_, err := s.CASMapping.Create(ctx, validDigest2, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
187+
require.NoError(s.T(), err)
188+
189+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
190+
s.NoError(err)
191+
s.Require().NotNil(mapping)
192+
s.Equal(nonDefaultBackend.ID, mapping.CASBackend.ID)
193+
})
194+
195+
s.Run("public download returns the default backend even when it is mapped last", func() {
196+
// Public mappings (workflow is public) across two backends, non-default created first.
197+
_, err := s.CASMapping.Create(ctx, validDigestPublic, nonDefaultBackend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
198+
require.NoError(s.T(), err)
199+
_, err = s.CASMapping.Create(ctx, validDigestPublic, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
200+
require.NoError(s.T(), err)
201+
202+
// A requester with no access to org1 falls back to the public mappings.
203+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestPublic, []uuid.UUID{uuid.New()}, nil)
204+
s.NoError(err)
205+
s.Require().NotNil(mapping)
206+
s.Equal(s.casBackend1.ID, mapping.CASBackend.ID)
207+
})
208+
}
209+
210+
// When RBAC is enabled for an org (projectIDs carries an entry for it), only mappings whose project
211+
// is in the visible set are reachable through that org.
212+
func (s *casMappingIntegrationSuite) TestCASMappingForDownloadRBAC() {
213+
ctx := context.Background()
214+
orgUUID := uuid.MustParse(s.org1.ID)
215+
216+
// A mapping in org1 scoped to a specific project.
217+
_, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{
218+
WorkflowRunID: &s.workflowRun.ID,
219+
ProjectID: &s.projectID,
220+
})
173221
require.NoError(s.T(), err)
174222

175-
testcases := []struct {
176-
name string
177-
digest string
178-
want []*biz.CASMapping
179-
wantErr bool
180-
}{
181-
{
182-
name: "validDigest",
183-
digest: validDigest,
184-
want: []*biz.CASMapping{
185-
{
186-
Digest: validDigest,
187-
CASBackend: &biz.CASBackend{ID: s.casBackend1.ID},
188-
WorkflowRunID: s.workflowRun.ID,
189-
OrgID: s.casBackend1.OrganizationID,
190-
Public: false,
191-
},
192-
{
193-
Digest: validDigest,
194-
CASBackend: &biz.CASBackend{ID: s.casBackend2.ID},
195-
WorkflowRunID: s.workflowRun.ID,
196-
OrgID: s.casBackend2.OrganizationID,
197-
Public: false,
198-
},
199-
{
200-
Digest: validDigest,
201-
CASBackend: &biz.CASBackend{ID: s.casBackend3.ID},
202-
WorkflowRunID: s.publicWorkflowRun.ID,
203-
OrgID: s.casBackend3.OrganizationID,
204-
Public: true,
205-
},
206-
},
207-
},
208-
{
209-
name: "validDigest2",
210-
digest: validDigest2,
211-
want: []*biz.CASMapping{
212-
{
213-
Digest: validDigest2,
214-
CASBackend: &biz.CASBackend{ID: s.casBackend1.ID},
215-
WorkflowRunID: s.workflowRun.ID,
216-
OrgID: s.casBackend1.OrganizationID,
217-
Public: false,
218-
},
219-
},
220-
},
221-
{
222-
name: "invalidDigest",
223-
digest: invalidDigest,
224-
want: []*biz.CASMapping{},
225-
},
226-
}
223+
s.Run("returned when the project is visible", func() {
224+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID},
225+
map[uuid.UUID][]uuid.UUID{orgUUID: {s.projectID}})
226+
s.NoError(err)
227+
s.Require().NotNil(mapping)
228+
s.Equal(s.casBackend1.ID, mapping.CASBackend.ID)
229+
})
227230

228-
for _, tc := range testcases {
229-
s.Run(tc.name, func() {
230-
got, err := s.CASMapping.FindByDigest(context.Background(), tc.digest)
231-
if tc.wantErr {
232-
s.Error(err)
233-
} else {
234-
s.NoError(err)
235-
if diff := cmp.Diff(tc.want, got,
236-
cmpopts.IgnoreFields(biz.CASMapping{}, "CreatedAt", "ID"),
237-
cmpopts.IgnoreTypes(biz.CASBackend{}),
238-
); diff != "" {
239-
assert.Failf(s.T(), "mismatch (-want +got):\n%s", diff)
240-
}
241-
}
242-
})
243-
}
231+
s.Run("not returned when the project is not visible", func() {
232+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID},
233+
map[uuid.UUID][]uuid.UUID{orgUUID: {uuid.New()}})
234+
s.Error(err)
235+
s.Nil(mapping)
236+
})
237+
238+
s.Run("not returned when RBAC is enabled with no visible projects", func() {
239+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{orgUUID},
240+
map[uuid.UUID][]uuid.UUID{orgUUID: {}})
241+
s.Error(err)
242+
s.Nil(mapping)
243+
})
244+
}
245+
246+
// Mappings pointing to a soft-deleted backend, or produced by a soft-deleted workflow, must not be
247+
// served for download.
248+
func (s *casMappingIntegrationSuite) TestCASMappingForDownloadSkipsSoftDeleted() {
249+
ctx := context.Background()
250+
251+
s.Run("org download skips a mapping whose backend is soft-deleted", func() {
252+
backend, err := s.CASBackend.Create(ctx, s.org1.ID, randomName(), "my-location", "to be deleted", backendType, nil, false, false, nil)
253+
require.NoError(s.T(), err)
254+
_, err = s.CASMapping.Create(ctx, validDigest3, backend.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
255+
require.NoError(s.T(), err)
256+
257+
// Reachable before the backend is deleted.
258+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest3, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
259+
s.NoError(err)
260+
s.Require().NotNil(mapping)
261+
262+
require.NoError(s.T(), s.CASBackend.SoftDelete(ctx, s.org1.ID, backend.ID.String()))
263+
264+
// The only mapping points to a deleted backend, so it is no longer served.
265+
mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest3, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
266+
s.Error(err)
267+
s.Nil(mapping)
268+
})
269+
270+
s.Run("public download skips a mapping whose workflow is soft-deleted", func() {
271+
_, err := s.CASMapping.Create(ctx, validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
272+
require.NoError(s.T(), err)
273+
274+
// A non-member can reach it through the public fallback while the workflow is public.
275+
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.New()}, nil)
276+
s.NoError(err)
277+
s.Require().NotNil(mapping)
278+
279+
require.NoError(s.T(), s.Workflow.Delete(ctx, s.org1.ID, s.publicWorkflow.ID.String()))
280+
281+
// Once the workflow is soft-deleted the mapping is no longer public.
282+
mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest2, []uuid.UUID{uuid.New()}, nil)
283+
s.Error(err)
284+
s.Nil(mapping)
285+
})
244286
}
245287

246288
func (s *casMappingIntegrationSuite) TestCreate() {
@@ -342,6 +384,8 @@ type casMappingIntegrationSuite struct {
342384
testhelpers.UseCasesEachTestSuite
343385
casBackend1, casBackend2, casBackend3 *biz.CASBackend
344386
workflowRun, publicWorkflowRun *biz.WorkflowRun
387+
publicWorkflow *biz.Workflow
388+
projectID uuid.UUID
345389
userOrg1And2, userOrg2 *biz.User
346390
org1, org2, orgNoUsers *biz.Organization
347391
}
@@ -379,8 +423,11 @@ func (s *casMappingIntegrationSuite) SetupTest() {
379423
workflow, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "test-workflow", OrgID: s.org1.ID, Project: "test-project"})
380424
assert.NoError(err)
381425

426+
s.projectID = workflow.ProjectID
427+
382428
publicWorkflow, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "test-workflow-public", OrgID: s.org1.ID, Public: true, Project: "test-project"})
383429
assert.NoError(err)
430+
s.publicWorkflow = publicWorkflow
384431

385432
// Find contract revision
386433
contractVersion, err := s.WorkflowContract.Describe(ctx, s.org1.ID, workflow.ContractID.String(), 0)

0 commit comments

Comments
 (0)