Skip to content

Commit 22932b1

Browse files
authored
feat(api-token): allow org-level tokens to manage project-scoped tokens (#2811)
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent 986a100 commit 22932b1

File tree

6 files changed

+226
-19
lines changed

6 files changed

+226
-19
lines changed

app/controlplane/internal/service/apitoken.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"time"
2121

2222
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
2324
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2425
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2526
errors "github.com/go-kratos/kratos/v2/errors"
@@ -53,6 +54,13 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre
5354
return nil, errors.BadRequest("invalid", "project is required")
5455
}
5556

57+
// Org-level API tokens can only create project-scoped tokens
58+
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
59+
if !req.ProjectReference.IsSet() {
60+
return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens")
61+
}
62+
}
63+
5664
// if the project is provided we make sure it exists and the user has permission to it
5765
var project *biz.Project
5866
if req.ProjectReference.IsSet() {
@@ -100,7 +108,13 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR
100108
defaultProjectFilter = []uuid.UUID{project.ID}
101109
}
102110

103-
tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(mapTokenScope(req.Scope)))
111+
// Org-level API tokens can only see project-scoped tokens
112+
scope := mapTokenScope(req.Scope)
113+
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
114+
scope = biz.APITokenScopeProject
115+
}
116+
117+
tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(scope))
104118
if err != nil {
105119
return nil, handleUseCaseErr(err, s.log)
106120
}
@@ -151,6 +165,13 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev
151165
return nil, errors.BadRequest("invalid", "you can not manage a global API token")
152166
}
153167

168+
// Org-level API tokens cannot revoke other org-level tokens
169+
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
170+
if t.ProjectID == nil {
171+
return nil, errors.Forbidden("forbidden", "org-level API tokens cannot revoke org-level tokens")
172+
}
173+
}
174+
154175
// Make sure the user has permission to revoke the token in the project
155176
if t.ProjectID != nil {
156177
if err := s.authorizeResource(ctx, authz.PolicyAPITokenRevoke, authz.ResourceTypeProject, *t.ProjectID); err != nil {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package service
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
25+
"github.com/google/uuid"
26+
"github.com/stretchr/testify/assert"
27+
)
28+
29+
func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) {
30+
t.Parallel()
31+
32+
svc := &APITokenService{service: newService()}
33+
34+
ctx := context.Background()
35+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString()})
36+
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), ProjectID: nil})
37+
38+
req := &pb.APITokenServiceCreateRequest{Name: "test-token"}
39+
40+
_, err := svc.Create(ctx, req)
41+
assert.Error(t, err)
42+
assert.Contains(t, err.Error(), "org-level API tokens must specify a project")
43+
}
44+
45+
func TestAPITokenService_List_OrgTokenForcesProjectScope(t *testing.T) {
46+
t.Parallel()
47+
48+
tests := []struct {
49+
name string
50+
token *entities.APIToken
51+
wantScope biz.APITokenScope
52+
}{
53+
{
54+
name: "org-level token forces project scope",
55+
token: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
56+
wantScope: biz.APITokenScopeProject,
57+
},
58+
{
59+
name: "project-scoped token does not override scope",
60+
token: &entities.APIToken{ID: uuid.NewString(), ProjectID: toUUIDPtr(uuid.New())},
61+
wantScope: "", // mapTokenScope returns "" for SCOPE_UNSPECIFIED
62+
},
63+
}
64+
65+
for _, tc := range tests {
66+
t.Run(tc.name, func(t *testing.T) {
67+
ctx := context.Background()
68+
ctx = entities.WithCurrentAPIToken(ctx, tc.token)
69+
70+
scope := mapTokenScope(pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED)
71+
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
72+
scope = biz.APITokenScopeProject
73+
}
74+
75+
assert.Equal(t, tc.wantScope, scope)
76+
})
77+
}
78+
}
79+
80+
func TestAPITokenService_Revoke_OrgTokenCannotRevokeOrgTokens(t *testing.T) {
81+
t.Parallel()
82+
83+
orgID := uuid.NewString()
84+
85+
tests := []struct {
86+
name string
87+
callerToken *entities.APIToken
88+
targetToken *biz.APIToken
89+
wantForbidden bool
90+
}{
91+
{
92+
name: "org-level token revoking org-level token is forbidden",
93+
callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
94+
targetToken: &biz.APIToken{
95+
ID: uuid.New(),
96+
OrganizationID: uuid.MustParse(orgID),
97+
ProjectID: nil,
98+
},
99+
wantForbidden: true,
100+
},
101+
{
102+
name: "org-level token revoking project token is allowed",
103+
callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
104+
targetToken: &biz.APIToken{
105+
ID: uuid.New(),
106+
OrganizationID: uuid.MustParse(orgID),
107+
ProjectID: toUUIDPtr(uuid.New()),
108+
},
109+
wantForbidden: false,
110+
},
111+
}
112+
113+
for _, tc := range tests {
114+
t.Run(tc.name, func(t *testing.T) {
115+
ctx := context.Background()
116+
ctx = entities.WithCurrentAPIToken(ctx, tc.callerToken)
117+
118+
forbidden := false
119+
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
120+
if tc.targetToken.ProjectID == nil {
121+
forbidden = true
122+
}
123+
}
124+
125+
assert.Equal(t, tc.wantForbidden, forbidden)
126+
})
127+
}
128+
}
129+
130+
func toUUIDPtr(id uuid.UUID) *uuid.UUID {
131+
return &id
132+
}

app/controlplane/pkg/biz/apitoken.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ type APITokenJWTConfig struct {
3535
SymmetricHmacKey string
3636
}
3737

38+
// orgLevelTokenPolicies are additional policies granted only to org-level tokens.
39+
// They allow managing project-scoped tokens.
40+
var orgLevelTokenPolicies = []*authz.Policy{
41+
authz.PolicyAPITokenCreate,
42+
authz.PolicyAPITokenList,
43+
authz.PolicyAPITokenRevoke,
44+
}
45+
3846
// APIToken is used for unattended access to the control plane API.
3947
type APIToken struct {
4048
ID uuid.UUID
@@ -204,6 +212,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
204212
policies = uc.DefaultAuthzPolicies
205213
}
206214

215+
// Org-level tokens additionally get project-level API token management policies
216+
if projectID == nil && orgUUID != nil {
217+
policies = append(policies, orgLevelTokenPolicies...)
218+
}
219+
207220
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
208221
// We store it since Chainloop will not have access to the JWT to check the expiration once created
209222
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)

app/controlplane/pkg/biz/apitoken_integration_test.go

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

24+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2425
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2526
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers"
2627
"github.com/golang-jwt/jwt/v4"
@@ -144,26 +145,55 @@ func (s *apiTokenTestSuite) TestCreate() {
144145
}
145146

146147
func (s *apiTokenTestSuite) TestAuthzPolicies() {
147-
// a new token has a new set of policies associated
148-
token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, &s.org.ID)
149-
require.NoError(s.T(), err)
148+
ctx := context.Background()
150149

151-
// With the new architecture, API token policies are stored in the database, not in Casbin
152-
// Verify that the token has the default policies stored
153-
s.Require().NotNil(token.Policies)
154-
s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies))
155-
156-
// Check that all default policies are present
157-
for _, expectedPolicy := range s.APIToken.DefaultAuthzPolicies {
158-
found := false
159-
for _, actualPolicy := range token.Policies {
160-
if actualPolicy.Resource == expectedPolicy.Resource && actualPolicy.Action == expectedPolicy.Action {
161-
found = true
162-
break
150+
s.Run("project-level token gets default policies", func() {
151+
token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1))
152+
require.NoError(s.T(), err)
153+
s.Require().NotNil(token.Policies)
154+
s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies))
155+
156+
for _, p := range s.APIToken.DefaultAuthzPolicies {
157+
found := false
158+
for _, actual := range token.Policies {
159+
if actual.Resource == p.Resource && actual.Action == p.Action {
160+
found = true
161+
break
162+
}
163163
}
164+
s.True(found, fmt.Sprintf("policy %s:%s not found", p.Resource, p.Action))
164165
}
165-
s.True(found, fmt.Sprintf("policy %s:%s not found", expectedPolicy.Resource, expectedPolicy.Action))
166-
}
166+
})
167+
168+
s.Run("org-level token gets default plus token management policies", func() {
169+
token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID)
170+
require.NoError(s.T(), err)
171+
s.Require().NotNil(token.Policies)
172+
173+
// All default policies must be present
174+
for _, p := range s.APIToken.DefaultAuthzPolicies {
175+
found := false
176+
for _, actual := range token.Policies {
177+
if actual.Resource == p.Resource && actual.Action == p.Action {
178+
found = true
179+
break
180+
}
181+
}
182+
s.True(found, fmt.Sprintf("default policy %s:%s not found", p.Resource, p.Action))
183+
}
184+
185+
// Additional org-level token management policies must be present
186+
for _, p := range []*authz.Policy{authz.PolicyAPITokenCreate, authz.PolicyAPITokenList, authz.PolicyAPITokenRevoke} {
187+
found := false
188+
for _, actual := range token.Policies {
189+
if actual.Resource == p.Resource && actual.Action == p.Action {
190+
found = true
191+
break
192+
}
193+
}
194+
s.True(found, fmt.Sprintf("org policy %s:%s not found", p.Resource, p.Action))
195+
}
196+
})
167197
}
168198

169199
func (s *apiTokenTestSuite) TestRevoke() {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Add API token management policies to existing org-level tokens.
2+
UPDATE "api_tokens"
3+
SET "policies" = "policies" || '[
4+
{"Resource": "api_token", "Action": "create"},
5+
{"Resource": "api_token", "Action": "list"},
6+
{"Resource": "api_token", "Action": "delete"}
7+
]'::jsonb
8+
WHERE "policies" IS NOT NULL
9+
AND "organization_id" IS NOT NULL
10+
AND "project_id" IS NULL;

app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4=
1+
h1:SDVo/094SOGNvK1KK35O4KpUY78MuSTZljVN58UGkO0=
22
20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M=
33
20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g=
44
20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI=
@@ -125,3 +125,4 @@ h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4=
125125
20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg=
126126
20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg=
127127
20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4=
128+
20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA=

0 commit comments

Comments
 (0)