From fc57a0d442072f25aa0e563b528562d6478cca9e Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 3 Mar 2026 12:19:41 +0100 Subject: [PATCH 1/5] allow org tokens to create project tokens Signed-off-by: Sylwester Piskozub --- app/controlplane/configs/config.devel.yaml | 8 +- app/controlplane/internal/service/apitoken.go | 23 ++- .../internal/service/apitoken_test.go | 132 ++++++++++++++++++ app/controlplane/pkg/biz/apitoken.go | 20 +++ .../ent/migrate/migrations/20260303120000.sql | 10 ++ 5 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 app/controlplane/internal/service/apitoken_test.go create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index dc546e062..5dd5d6f21 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -98,10 +98,10 @@ prometheus_integration: - org_name: "development" # Policy providers configuration -# policy_providers: -# - name: chainloop -# default: true -# url: http://localhost:8002/v1 +policy_providers: + - name: chainloop + default: true + url: http://localhost:8002/v1 enable_profiler: true # federated_authentication: diff --git a/app/controlplane/internal/service/apitoken.go b/app/controlplane/internal/service/apitoken.go index a0a41bb0f..bf035bb15 100644 --- a/app/controlplane/internal/service/apitoken.go +++ b/app/controlplane/internal/service/apitoken.go @@ -20,6 +20,7 @@ import ( "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" errors "github.com/go-kratos/kratos/v2/errors" @@ -53,6 +54,13 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre return nil, errors.BadRequest("invalid", "project is required") } + // Org-level API tokens can only create project-scoped tokens + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if !req.ProjectReference.IsSet() { + return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens") + } + } + // if the project is provided we make sure it exists and the user has permission to it var project *biz.Project if req.ProjectReference.IsSet() { @@ -100,7 +108,13 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR defaultProjectFilter = []uuid.UUID{project.ID} } - tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(mapTokenScope(req.Scope))) + // Org-level API tokens can only see project-scoped tokens + scope := mapTokenScope(req.Scope) + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + scope = biz.APITokenScopeProject + } + + tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(scope)) if err != nil { return nil, handleUseCaseErr(err, s.log) } @@ -151,6 +165,13 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev return nil, errors.BadRequest("invalid", "you can not manage a global API token") } + // Org-level API tokens cannot revoke other org-level tokens + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if t.ProjectID == nil { + return nil, errors.Forbidden("forbidden", "org-level API tokens cannot revoke org-level tokens") + } + } + // Make sure the user has permission to revoke the token in the project if t.ProjectID != nil { if err := s.authorizeResource(ctx, authz.PolicyAPITokenRevoke, authz.ResourceTypeProject, *t.ProjectID); err != nil { diff --git a/app/controlplane/internal/service/apitoken_test.go b/app/controlplane/internal/service/apitoken_test.go new file mode 100644 index 000000000..7483db50a --- /dev/null +++ b/app/controlplane/internal/service/apitoken_test.go @@ -0,0 +1,132 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) { + t.Parallel() + + svc := &APITokenService{service: newService()} + + ctx := context.Background() + ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString()}) + ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}) + + req := &pb.APITokenServiceCreateRequest{Name: "test-token"} + + _, err := svc.Create(ctx, req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "org-level API tokens must specify a project") +} + +func TestAPITokenService_List_OrgTokenForcesProjectScope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token *entities.APIToken + wantScope biz.APITokenScope + }{ + { + name: "org-level token forces project scope", + token: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + wantScope: biz.APITokenScopeProject, + }, + { + name: "project-scoped token does not override scope", + token: &entities.APIToken{ID: uuid.NewString(), ProjectID: toUUIDPtr(uuid.New())}, + wantScope: "", // mapTokenScope returns "" for SCOPE_UNSPECIFIED + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = entities.WithCurrentAPIToken(ctx, tc.token) + + scope := mapTokenScope(pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED) + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + scope = biz.APITokenScopeProject + } + + assert.Equal(t, tc.wantScope, scope) + }) + } +} + +func TestAPITokenService_Revoke_OrgTokenCannotRevokeOrgTokens(t *testing.T) { + t.Parallel() + + orgID := uuid.NewString() + + tests := []struct { + name string + callerToken *entities.APIToken + targetToken *biz.APIToken + wantForbidden bool + }{ + { + name: "org-level token revoking org-level token is forbidden", + callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + targetToken: &biz.APIToken{ + ID: uuid.New(), + OrganizationID: uuid.MustParse(orgID), + ProjectID: nil, + }, + wantForbidden: true, + }, + { + name: "org-level token revoking project token is allowed", + callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + targetToken: &biz.APIToken{ + ID: uuid.New(), + OrganizationID: uuid.MustParse(orgID), + ProjectID: toUUIDPtr(uuid.New()), + }, + wantForbidden: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = entities.WithCurrentAPIToken(ctx, tc.callerToken) + + forbidden := false + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if tc.targetToken.ProjectID == nil { + forbidden = true + } + } + + assert.Equal(t, tc.wantForbidden, forbidden) + }) + } +} + +func toUUIDPtr(id uuid.UUID) *uuid.UUID { + return &id +} diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 5c376afe5..485ea5bd8 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -35,6 +35,21 @@ type APITokenJWTConfig struct { SymmetricHmacKey string } +// OrgLevelAPITokenPolicies are additional policies granted only to org-level tokens. +// They allow managing project-scoped tokens. +var OrgLevelAPITokenPolicies = []*authz.Policy{ + authz.PolicyAPITokenCreate, + authz.PolicyAPITokenList, + authz.PolicyAPITokenRevoke, +} + +// withOrgLevelPolicies returns a new slice with OrgLevelAPITokenPolicies appended. +func withOrgLevelPolicies(policies []*authz.Policy) []*authz.Policy { + result := make([]*authz.Policy, len(policies), len(policies)+len(OrgLevelAPITokenPolicies)) + copy(result, policies) + return append(result, OrgLevelAPITokenPolicies...) +} + // APIToken is used for unattended access to the control plane API. type APIToken struct { ID uuid.UUID @@ -204,6 +219,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description policies = uc.DefaultAuthzPolicies } + // Org-level tokens additionally get project-level API token management policies + if projectID == nil && orgUUID != nil { + policies = withOrgLevelPolicies(policies) + } + // NOTE: the expiration time is stored just for reference, it's also encoded in the JWT // We store it since Chainloop will not have access to the JWT to check the expiration once created token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies) diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql new file mode 100644 index 000000000..29bb9ab81 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql @@ -0,0 +1,10 @@ +-- Add API token management policies to existing org-level tokens. +UPDATE "api_tokens" +SET "policies" = "policies" || '[ + {"Resource": "api_token", "Action": "create"}, + {"Resource": "api_token", "Action": "list"}, + {"Resource": "api_token", "Action": "delete"} +]'::jsonb +WHERE "policies" IS NOT NULL + AND "organization_id" IS NOT NULL + AND "project_id" IS NULL; From 1a6ad75f1eb0be6a6f26f4138a228ef2cf5f5bf8 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 3 Mar 2026 12:30:50 +0100 Subject: [PATCH 2/5] rm config; add atlas Signed-off-by: Sylwester Piskozub --- app/controlplane/configs/config.devel.yaml | 8 ++++---- .../pkg/data/ent/migrate/migrations/atlas.sum | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index 5dd5d6f21..dc546e062 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -98,10 +98,10 @@ prometheus_integration: - org_name: "development" # Policy providers configuration -policy_providers: - - name: chainloop - default: true - url: http://localhost:8002/v1 +# policy_providers: +# - name: chainloop +# default: true +# url: http://localhost:8002/v1 enable_profiler: true # federated_authentication: diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index e5390dda9..0285ca20a 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:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4= +h1:SDVo/094SOGNvK1KK35O4KpUY78MuSTZljVN58UGkO0= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -125,3 +125,4 @@ h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4= 20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg= 20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg= 20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4= +20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA= From c3bfddc51fd64e93cd7e45998facd4c1ff7515cb Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 3 Mar 2026 12:43:32 +0100 Subject: [PATCH 3/5] fix test Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/biz/apitoken_integration_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 2175188ad..7617da862 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -150,11 +150,12 @@ func (s *apiTokenTestSuite) TestAuthzPolicies() { // With the new architecture, API token policies are stored in the database, not in Casbin // Verify that the token has the default policies stored + expectedPolicies := append(s.APIToken.DefaultAuthzPolicies, biz.OrgLevelAPITokenPolicies...) s.Require().NotNil(token.Policies) - s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies)) + s.Len(token.Policies, len(expectedPolicies)) - // Check that all default policies are present - for _, expectedPolicy := range s.APIToken.DefaultAuthzPolicies { + // Check that all default policies for org-scoped token are present + for _, expectedPolicy := range expectedPolicies { found := false for _, actualPolicy := range token.Policies { if actualPolicy.Resource == expectedPolicy.Resource && actualPolicy.Action == expectedPolicy.Action { From bfe1290f1bed9e4ba4d419231ab88a2fa14dc006 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 3 Mar 2026 13:43:54 +0100 Subject: [PATCH 4/5] lint Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/biz/apitoken_integration_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 7617da862..94403c5f6 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -21,6 +21,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" "github.com/golang-jwt/jwt/v4" @@ -150,7 +151,9 @@ func (s *apiTokenTestSuite) TestAuthzPolicies() { // With the new architecture, API token policies are stored in the database, not in Casbin // Verify that the token has the default policies stored - expectedPolicies := append(s.APIToken.DefaultAuthzPolicies, biz.OrgLevelAPITokenPolicies...) + expectedPolicies := make([]*authz.Policy, 0, len(s.APIToken.DefaultAuthzPolicies)+len(biz.OrgLevelAPITokenPolicies)) + expectedPolicies = append(expectedPolicies, s.APIToken.DefaultAuthzPolicies...) + expectedPolicies = append(expectedPolicies, biz.OrgLevelAPITokenPolicies...) s.Require().NotNil(token.Policies) s.Len(token.Policies, len(expectedPolicies)) From 6de09cab96003f6fb13ae878155f2fd40fe1adac Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 4 Mar 2026 13:33:07 +0100 Subject: [PATCH 5/5] refactor Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/biz/apitoken.go | 13 +--- .../pkg/biz/apitoken_integration_test.go | 66 +++++++++++++------ 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 485ea5bd8..0d6ac86b7 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -35,21 +35,14 @@ type APITokenJWTConfig struct { SymmetricHmacKey string } -// OrgLevelAPITokenPolicies are additional policies granted only to org-level tokens. +// orgLevelTokenPolicies are additional policies granted only to org-level tokens. // They allow managing project-scoped tokens. -var OrgLevelAPITokenPolicies = []*authz.Policy{ +var orgLevelTokenPolicies = []*authz.Policy{ authz.PolicyAPITokenCreate, authz.PolicyAPITokenList, authz.PolicyAPITokenRevoke, } -// withOrgLevelPolicies returns a new slice with OrgLevelAPITokenPolicies appended. -func withOrgLevelPolicies(policies []*authz.Policy) []*authz.Policy { - result := make([]*authz.Policy, len(policies), len(policies)+len(OrgLevelAPITokenPolicies)) - copy(result, policies) - return append(result, OrgLevelAPITokenPolicies...) -} - // APIToken is used for unattended access to the control plane API. type APIToken struct { ID uuid.UUID @@ -221,7 +214,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description // Org-level tokens additionally get project-level API token management policies if projectID == nil && orgUUID != nil { - policies = withOrgLevelPolicies(policies) + policies = append(policies, orgLevelTokenPolicies...) } // NOTE: the expiration time is stored just for reference, it's also encoded in the JWT diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 94403c5f6..9a6416edc 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -145,29 +145,55 @@ func (s *apiTokenTestSuite) TestCreate() { } func (s *apiTokenTestSuite) TestAuthzPolicies() { - // a new token has a new set of policies associated - token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, &s.org.ID) - require.NoError(s.T(), err) + ctx := context.Background() - // With the new architecture, API token policies are stored in the database, not in Casbin - // Verify that the token has the default policies stored - expectedPolicies := make([]*authz.Policy, 0, len(s.APIToken.DefaultAuthzPolicies)+len(biz.OrgLevelAPITokenPolicies)) - expectedPolicies = append(expectedPolicies, s.APIToken.DefaultAuthzPolicies...) - expectedPolicies = append(expectedPolicies, biz.OrgLevelAPITokenPolicies...) - s.Require().NotNil(token.Policies) - s.Len(token.Policies, len(expectedPolicies)) - - // Check that all default policies for org-scoped token are present - for _, expectedPolicy := range expectedPolicies { - found := false - for _, actualPolicy := range token.Policies { - if actualPolicy.Resource == expectedPolicy.Resource && actualPolicy.Action == expectedPolicy.Action { - found = true - break + s.Run("project-level token gets default policies", func() { + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) + require.NoError(s.T(), err) + s.Require().NotNil(token.Policies) + s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies)) + + for _, p := range s.APIToken.DefaultAuthzPolicies { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } } + s.True(found, fmt.Sprintf("policy %s:%s not found", p.Resource, p.Action)) } - s.True(found, fmt.Sprintf("policy %s:%s not found", expectedPolicy.Resource, expectedPolicy.Action)) - } + }) + + s.Run("org-level token gets default plus token management policies", func() { + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) + require.NoError(s.T(), err) + s.Require().NotNil(token.Policies) + + // All default policies must be present + for _, p := range s.APIToken.DefaultAuthzPolicies { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } + } + s.True(found, fmt.Sprintf("default policy %s:%s not found", p.Resource, p.Action)) + } + + // Additional org-level token management policies must be present + for _, p := range []*authz.Policy{authz.PolicyAPITokenCreate, authz.PolicyAPITokenList, authz.PolicyAPITokenRevoke} { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } + } + s.True(found, fmt.Sprintf("org policy %s:%s not found", p.Resource, p.Action)) + } + }) } func (s *apiTokenTestSuite) TestRevoke() {