@@ -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
7172func (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
291411func TestWorkflowContractApply (t * testing.T ) {
0 commit comments