Skip to content

Commit 674eb26

Browse files
authored
fix(rbac): refuse confined callers creating org-level contracts through apply (#3510)
1 parent 9d5b368 commit 674eb26

2 files changed

Lines changed: 150 additions & 30 deletions

File tree

‎app/controlplane/internal/service/workflowcontract.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,9 +336,9 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon
336336
}
337337

338338
// Apply has no project to scope a new contract to, so what it creates is organization-level,
339-
// which a product token never changes. Create asks such a caller for a project instead.
340-
if entities.CurrentAPIToken(ctx).IsProductScoped() {
341-
return nil, errors.Forbidden("forbidden", "a product-scoped token cannot create an organization-level contract; create it in one of the product's projects")
339+
// which a caller confined to projects never changes. Create asks such a caller for a project instead.
340+
if rbacEnabled(ctx) {
341+
return nil, errors.Forbidden("forbidden", "you can not create an organization-level contract; create it in a project instead")
342342
}
343343

344344
// On a dry run we report that the contract would be created, without persisting it

‎app/controlplane/internal/service/workflowcontract_integration_test.go‎

Lines changed: 147 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"testing"
2222

2323
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
2425
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2526
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2627
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers"
@@ -69,7 +70,12 @@ func (s *workflowContractApplyIntegrationTestSuite) apply(rawSchema string, dryR
6970
}
7071

7172
func (s *workflowContractApplyIntegrationTestSuite) latestRevision() int {
72-
contract, err := s.WorkflowContract.FindByNameInOrg(s.ctx, s.org.ID, applyContractName)
73+
return s.revisionOf(applyContractName)
74+
}
75+
76+
// revisionOf returns the latest revision of the named contract, or 0 if it doesn't exist.
77+
func (s *workflowContractApplyIntegrationTestSuite) revisionOf(name string) int {
78+
contract, err := s.WorkflowContract.FindByNameInOrg(s.ctx, s.org.ID, name)
7379
if err != nil && biz.IsNotFound(err) {
7480
return 0
7581
}
@@ -254,38 +260,152 @@ func (s *workflowContractApplyIntegrationTestSuite) TestApplyBatchExemption() {
254260
}
255261
}
256262

257-
// A product token reaches only its projects, and Apply has no project to scope a new contract to,
258-
// so it can't create one: that would be an organization-level contract. Create requires a project
259-
// of such a caller for the same reason.
260-
func (s *workflowContractApplyIntegrationTestSuite) TestApplyRefusesAProductTokenCreatingAContract() {
261-
productID := uuid.New()
262-
ctx := entities.WithCurrentAPIToken(s.ctx, &entities.APIToken{
263-
ID: uuid.NewString(),
264-
Name: "ci",
265-
Scope: biz.ToPtr(authz.ResourceTypeProduct),
266-
ScopeID: &productID,
267-
ProjectIDs: []uuid.UUID{},
268-
})
263+
// namedContract returns a minimal contract with the given name, so each case applies its own.
264+
// Extra materials change its content.
265+
func namedContract(name string, extraMaterials ...string) []byte {
266+
raw := `
267+
apiVersion: chainloop.dev/v1
268+
kind: Contract
269+
metadata:
270+
name: ` + name + `
271+
spec:
272+
materials:
273+
- type: ARTIFACT
274+
name: my-artifact
275+
`
276+
for _, m := range extraMaterials {
277+
raw += " - type: ARTIFACT\n name: " + m + "\n"
278+
}
279+
280+
return []byte(raw)
281+
}
282+
283+
type applyCaller struct {
284+
name string
285+
ctx context.Context
286+
}
287+
288+
// apiTokenContext returns the suite context acting as an API token with the given scope.
289+
func (s *workflowContractApplyIntegrationTestSuite) apiTokenContext(scope authz.ResourceType, configure func(*entities.APIToken)) context.Context {
290+
token := &entities.APIToken{ID: uuid.NewString(), Name: "ci", Scope: &scope}
291+
if configure != nil {
292+
configure(token)
293+
}
294+
295+
return entities.WithCurrentAPIToken(s.ctx, token)
296+
}
297+
298+
// confinedTokens are the API tokens confined to projects that reach the given one. Apply keys on
299+
// the token's scope kind, so the scope is all they need here. Their policies are checked before,
300+
// by the authz interceptor: every token holds contract create and update by default.
301+
func (s *workflowContractApplyIntegrationTestSuite) confinedTokens(projectID uuid.UUID) []applyCaller {
302+
workflowID, productID := uuid.New(), uuid.New()
303+
304+
return []applyCaller{
305+
{name: "project token", ctx: s.apiTokenContext(authz.ResourceTypeProject, func(t *entities.APIToken) {
306+
t.ScopeID, t.ProjectID = &projectID, &projectID
307+
})},
308+
{name: "workflow token", ctx: s.apiTokenContext(authz.ResourceTypeProject, func(t *entities.APIToken) {
309+
t.ScopeID, t.ProjectID, t.WorkflowID = &projectID, &projectID, &workflowID
310+
})},
311+
{name: "product token", ctx: s.apiTokenContext(authz.ResourceTypeProduct, func(t *entities.APIToken) {
312+
t.ScopeID, t.ProjectIDs = &productID, []uuid.UUID{projectID}
313+
})},
314+
}
315+
}
316+
317+
// confinedCallers adds to the confined tokens the users confined to projects. Apply keys on their
318+
// organization role, through which members and contributors also hold contract create and update.
319+
func (s *workflowContractApplyIntegrationTestSuite) confinedCallers() []applyCaller {
320+
return append(s.confinedTokens(uuid.New()),
321+
applyCaller{name: "org member", ctx: usercontext.WithAuthzSubject(s.ctx, string(authz.RoleOrgMember))},
322+
applyCaller{name: "org contributor", ctx: usercontext.WithAuthzSubject(s.ctx, string(authz.RoleOrgContributor))},
323+
)
324+
}
325+
326+
// Apply has no project to scope a new contract to, so what it creates is organization-level. A
327+
// caller confined to projects can't create one, not even on a dry run. Create requires a project of
328+
// such a caller for the same reason.
329+
func (s *workflowContractApplyIntegrationTestSuite) TestApplyRefusesAConfinedCallerCreatingAContract() {
330+
for i, tc := range s.confinedCallers() {
331+
s.Run(tc.name, func() {
332+
name := fmt.Sprintf("confined-create-%d", i)
333+
334+
for _, dryRun := range []bool{true, false} {
335+
_, err := s.svc.Apply(tc.ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: namedContract(name), DryRun: dryRun})
336+
s.Require().Error(err, "dry run %t", dryRun)
337+
s.True(kerrors.IsForbidden(err), "dry run %t: got %v", dryRun, err)
338+
s.Equal(0, s.revisionOf(name), "dry run %t: nothing is created", dryRun)
339+
}
340+
})
341+
}
342+
}
269343

270-
for _, dryRun := range []bool{true, false} {
271-
s.Run(fmt.Sprintf("dry run %t", dryRun), func() {
272-
_, err := s.svc.Apply(ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: []byte(applyContractV1), DryRun: dryRun})
344+
// Updating an organization-level contract through Apply was already refused to a confined caller.
345+
func (s *workflowContractApplyIntegrationTestSuite) TestApplyRefusesAConfinedCallerUpdatingAnOrgContract() {
346+
s.apply(applyContractV1, false)
347+
348+
for _, tc := range s.confinedCallers() {
349+
s.Run(tc.name, func() {
350+
_, err := s.svc.Apply(tc.ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: []byte(applyContractV2)})
273351
s.Require().Error(err)
274-
s.True(kerrors.IsForbidden(err), "got %v", err)
275-
s.Equal(0, s.latestRevision(), "nothing is created")
352+
s.True(kerrors.IsBadRequest(err), "got %v", err)
353+
s.ErrorContains(err, "you can not manage a global contract")
354+
s.Equal(1, s.latestRevision(), "the contract is unchanged")
276355
})
277356
}
357+
}
278358

279-
s.Run("nor update an existing organization-level contract", func() {
280-
s.apply(applyContractV1, false)
281-
before := s.latestRevision()
359+
// A token confined to a project still updates that project's contracts through Apply.
360+
func (s *workflowContractApplyIntegrationTestSuite) TestApplyLetsAConfinedTokenUpdateItsProjectContract() {
361+
project, err := s.Project.Create(context.Background(), s.org.ID, "apply-project")
362+
s.Require().NoError(err)
282363

283-
_, err := s.svc.Apply(ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: []byte(applyContractV2)})
284-
s.Require().Error(err)
285-
s.True(kerrors.IsBadRequest(err), "got %v", err)
286-
s.ErrorContains(err, "you can not manage a global contract")
287-
s.Equal(before, s.latestRevision(), "the contract is unchanged")
288-
})
364+
for i, tc := range s.confinedTokens(project.ID) {
365+
s.Run(tc.name, func() {
366+
name := fmt.Sprintf("project-update-%d", i)
367+
_, err := s.WorkflowContract.Create(context.Background(), &biz.WorkflowContractCreateOpts{
368+
OrgID: s.org.ID,
369+
Name: name,
370+
RawSchema: namedContract(name),
371+
ProjectID: &project.ID,
372+
})
373+
s.Require().NoError(err)
374+
375+
resp, err := s.svc.Apply(tc.ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: namedContract(name, "another-artifact")})
376+
s.Require().NoError(err)
377+
s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED, resp.GetStatus())
378+
s.Equal(2, s.revisionOf(name))
379+
})
380+
}
381+
}
382+
383+
// Organization-wide tokens and organization administrators still create contracts through Apply.
384+
func (s *workflowContractApplyIntegrationTestSuite) TestApplyLetsAnUnconfinedCallerCreateAContract() {
385+
testCases := []applyCaller{
386+
{name: "org token", ctx: s.apiTokenContext(authz.ResourceTypeOrganization, func(t *entities.APIToken) {
387+
orgID := uuid.MustParse(s.org.ID)
388+
t.ScopeID = &orgID
389+
})},
390+
{name: "instance token", ctx: s.apiTokenContext(authz.ResourceTypeInstance, nil)},
391+
{name: "org owner", ctx: usercontext.WithAuthzSubject(s.ctx, string(authz.RoleOwner))},
392+
{name: "org admin", ctx: usercontext.WithAuthzSubject(s.ctx, string(authz.RoleAdmin))},
393+
}
394+
395+
for i, tc := range testCases {
396+
s.Run(tc.name, func() {
397+
name := fmt.Sprintf("unconfined-create-%d", i)
398+
399+
resp, err := s.svc.Apply(tc.ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: namedContract(name), DryRun: true})
400+
s.Require().NoError(err)
401+
s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, resp.GetStatus())
402+
403+
resp, err = s.svc.Apply(tc.ctx, &pb.WorkflowContractServiceApplyRequest{RawSchema: namedContract(name)})
404+
s.Require().NoError(err)
405+
s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, resp.GetStatus())
406+
s.Equal(1, s.revisionOf(name))
407+
})
408+
}
289409
}
290410

291411
func TestWorkflowContractApply(t *testing.T) {

0 commit comments

Comments
 (0)