Skip to content

Commit fc57a0d

Browse files
committed
allow org tokens to create project tokens
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent f165f77 commit fc57a0d

5 files changed

Lines changed: 188 additions & 5 deletions

File tree

app/controlplane/configs/config.devel.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,10 @@ prometheus_integration:
9898
- org_name: "development"
9999

100100
# Policy providers configuration
101-
# policy_providers:
102-
# - name: chainloop
103-
# default: true
104-
# url: http://localhost:8002/v1
101+
policy_providers:
102+
- name: chainloop
103+
default: true
104+
url: http://localhost:8002/v1
105105

106106
enable_profiler: true
107107
# federated_authentication:

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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ type APITokenJWTConfig struct {
3535
SymmetricHmacKey string
3636
}
3737

38+
// OrgLevelAPITokenPolicies are additional policies granted only to org-level tokens.
39+
// They allow managing project-scoped tokens.
40+
var OrgLevelAPITokenPolicies = []*authz.Policy{
41+
authz.PolicyAPITokenCreate,
42+
authz.PolicyAPITokenList,
43+
authz.PolicyAPITokenRevoke,
44+
}
45+
46+
// withOrgLevelPolicies returns a new slice with OrgLevelAPITokenPolicies appended.
47+
func withOrgLevelPolicies(policies []*authz.Policy) []*authz.Policy {
48+
result := make([]*authz.Policy, len(policies), len(policies)+len(OrgLevelAPITokenPolicies))
49+
copy(result, policies)
50+
return append(result, OrgLevelAPITokenPolicies...)
51+
}
52+
3853
// APIToken is used for unattended access to the control plane API.
3954
type APIToken struct {
4055
ID uuid.UUID
@@ -204,6 +219,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
204219
policies = uc.DefaultAuthzPolicies
205220
}
206221

222+
// Org-level tokens additionally get project-level API token management policies
223+
if projectID == nil && orgUUID != nil {
224+
policies = withOrgLevelPolicies(policies)
225+
}
226+
207227
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
208228
// We store it since Chainloop will not have access to the JWT to check the expiration once created
209229
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)
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;

0 commit comments

Comments
 (0)