Skip to content

Commit 49466dd

Browse files
authored
feat(api-token): add instance admin token (#2657)
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent 0104e4e commit 49466dd

19 files changed

Lines changed: 309 additions & 165 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre
6969
*expiresIn = req.ExpiresIn.AsDuration()
7070
}
7171

72-
token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, currentOrg.ID, biz.APITokenWithProject(project))
72+
token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, &currentOrg.ID, biz.APITokenWithProject(project))
7373
if err != nil {
7474
return nil, handleUseCaseErr(err, s.log)
7575
}

‎app/controlplane/internal/usercontext/apitoken_middleware.go‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
7474
// Project ID is optional
7575
projectID, _ := genericClaims["project_id"].(string)
7676

77-
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID)
77+
// Scope is optional
78+
scope, _ := genericClaims["scope"].(string)
79+
80+
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, scope)
7881
if err != nil {
7982
return nil, fmt.Errorf("error setting current org and user: %w", err)
8083
}
@@ -123,7 +126,7 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC *
123126
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
124127
}
125128

126-
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID)
129+
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.Scope)
127130
if err != nil {
128131
return nil, fmt.Errorf("error setting current org and user: %w", err)
129132
}
@@ -160,7 +163,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs
160163
}
161164

162165
// Set the current organization and API-Token in the context
163-
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim string) (context.Context, error) {
166+
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, scope string) (context.Context, error) {
164167
if tokenID == "" {
165168
return nil, errors.New("error retrieving the key ID from the API token")
166169
}
@@ -184,16 +187,35 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
184187
return nil, errors.New("API token revoked")
185188
}
186189

187-
// Find the associated organization
188-
org, err := orgUC.FindByID(ctx, token.OrganizationID.String())
189-
if err != nil {
190-
return nil, fmt.Errorf("error retrieving the organization: %w", err)
191-
} else if org == nil {
192-
return nil, errors.New("organization not found")
193-
}
190+
// Handle instance admin tokens
191+
if scope == authz.ScopeInstanceAdmin {
192+
// Check if org name provided in header
193+
orgName, _ := entities.GetOrganizationNameFromHeader(ctx)
194+
if orgName != "" {
195+
// Load organization from header
196+
org, err := orgUC.FindByName(ctx, orgName)
197+
if err != nil {
198+
return nil, fmt.Errorf("error retrieving the organization: %w", err)
199+
} else if org == nil {
200+
return nil, errors.New("organization not found")
201+
}
194202

195-
// Set the current organization and API-Token in the context
196-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
203+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
204+
}
205+
// If no org header, org context remains unset, operations will either:
206+
// 1. Work without org context
207+
// 2. Fail appropriately if they require org context
208+
} else {
209+
org, err := orgUC.FindByID(ctx, token.OrganizationID.String())
210+
if err != nil {
211+
return nil, fmt.Errorf("error retrieving the organization: %w", err)
212+
} else if org == nil {
213+
return nil, errors.New("organization not found")
214+
}
215+
216+
// Set the current organization in the context
217+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
218+
}
197219

198220
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
199221
ID: token.ID.String(),
@@ -203,6 +225,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
203225
ProjectID: token.ProjectID,
204226
ProjectName: token.ProjectName,
205227
Policies: token.Policies,
228+
Scope: scope,
206229
})
207230

208231
// Set the authorization subject that will be used to check the policies

‎app/controlplane/internal/usercontext/entities/apitoken.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type APIToken struct {
3333
ProjectName *string
3434
// ACL policies for this token. Used for authorization checks.
3535
Policies []*authz.Policy
36+
Scope string
3637
}
3738

3839
func WithCurrentAPIToken(ctx context.Context, token *APIToken) context.Context {

‎app/controlplane/pkg/authz/authz.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ const (
9797
// Product roles
9898
RoleProductViewer Role = "role:product:viewer"
9999
RoleProductAdmin Role = "role:product:admin"
100+
101+
// Scope for instance admin tokens
102+
ScopeInstanceAdmin = "INSTANCE_ADMIN"
100103
)
101104

102105
var (

‎app/controlplane/pkg/biz/apitoken.go‎

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ type APIToken struct {
5959
}
6060

6161
type APITokenRepo interface {
62-
Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error)
62+
Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error)
6363
List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error)
64-
Revoke(ctx context.Context, orgID, ID uuid.UUID) error
64+
Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error
6565
UpdateExpiration(ctx context.Context, ID uuid.UUID, expiresAt time.Time) error
6666
UpdateLastUsedAt(ctx context.Context, ID uuid.UUID, lastUsedAt time.Time) error
6767
FindByID(ctx context.Context, ID uuid.UUID) (*APIToken, error)
@@ -131,6 +131,7 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig,
131131
type apiTokenOptions struct {
132132
project *Project
133133
showOnlySystemTokens bool
134+
policies []*authz.Policy
134135
}
135136

136137
type APITokenCreateOpt func(*apiTokenOptions)
@@ -141,16 +142,34 @@ func APITokenWithProject(project *Project) APITokenCreateOpt {
141142
}
142143
}
143144

145+
func APITokenWithPolicies(policies []*authz.Policy) APITokenCreateOpt {
146+
return func(o *apiTokenOptions) {
147+
o.policies = policies
148+
}
149+
}
150+
144151
// expires in is a string that can be parsed by time.ParseDuration
145-
func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID string, opts ...APITokenCreateOpt) (*APIToken, error) {
152+
func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID *string, opts ...APITokenCreateOpt) (*APIToken, error) {
146153
options := &apiTokenOptions{}
147154
for _, opt := range opts {
148155
opt(options)
149156
}
150157

151-
orgUUID, err := uuid.Parse(orgID)
152-
if err != nil {
153-
return nil, NewErrInvalidUUID(err)
158+
// Parse organization ID if provided
159+
var orgUUID *uuid.UUID
160+
var org *Organization
161+
if orgID != nil && *orgID != "" {
162+
parsed, err := uuid.Parse(*orgID)
163+
if err != nil {
164+
return nil, NewErrInvalidUUID(err)
165+
}
166+
orgUUID = &parsed
167+
168+
// Retrieve the organization
169+
org, err = uc.orgUseCase.FindByID(ctx, *orgID)
170+
if err != nil {
171+
return nil, fmt.Errorf("finding organization: %w", err)
172+
}
154173
}
155174

156175
if name == "" {
@@ -170,22 +189,21 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
170189
*expiresAt = time.Now().Add(*expiresIn)
171190
}
172191

173-
// Retrieve the organization
174-
org, err := uc.orgUseCase.FindByID(ctx, orgID)
175-
if err != nil {
176-
return nil, fmt.Errorf("finding organization: %w", err)
177-
}
178-
179192
// If a project is provided, we store it in the token
180193
var projectID *uuid.UUID
181194
if options.project != nil {
182195
projectID = ToPtr(options.project.ID)
183196
}
184197

198+
// Use provided policies if present, otherwise use defaults
199+
policies := options.policies
200+
if policies == nil {
201+
policies = uc.DefaultAuthzPolicies
202+
}
203+
185204
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
186205
// We store it since Chainloop will not have access to the JWT to check the expiration once created
187-
// Pass default policies to be stored with the token
188-
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, uc.DefaultAuthzPolicies)
206+
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)
189207
if err != nil {
190208
if IsErrAlreadyExists(err) {
191209
return nil, NewErrAlreadyExistsStr("name already taken")
@@ -194,21 +212,26 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
194212
}
195213

196214
generationOpts := &apitoken.GenerateJWTOptions{
197-
OrgID: token.OrganizationID,
198-
OrgName: org.Name,
199215
KeyID: token.ID,
200216
KeyName: name,
201217
ExpiresAt: expiresAt,
202218
}
203219

220+
// Set org info if available or instance-level token scope
221+
if org != nil {
222+
generationOpts.OrgID = &token.OrganizationID
223+
generationOpts.OrgName = &org.Name
224+
} else {
225+
generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin)
226+
}
227+
204228
if projectID != nil {
205229
generationOpts.ProjectID = ToPtr(options.project.ID)
206230
generationOpts.ProjectName = ToPtr(options.project.Name)
207231
}
208232

209233
// generate the JWT
210234
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)
211-
212235
if err != nil {
213236
return nil, fmt.Errorf("generating jwt: %w", err)
214237
}
@@ -221,7 +244,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
221244
},
222245
APITokenDescription: description,
223246
ExpiresAt: expiresAt,
224-
}, &orgUUID)
247+
}, orgUUID)
225248

226249
return token, nil
227250
}
@@ -239,19 +262,26 @@ func (uc *APITokenUseCase) RegenerateJWT(ctx context.Context, tokenID uuid.UUID,
239262
return nil, fmt.Errorf("finding token: %w", err)
240263
}
241264

242-
org, err := uc.orgUseCase.FindByID(ctx, token.OrganizationID.String())
243-
if err != nil {
244-
return nil, fmt.Errorf("finding organization: %w", err)
245-
}
246-
247265
generationOpts := &apitoken.GenerateJWTOptions{
248-
OrgID: token.OrganizationID,
249-
OrgName: org.Name,
250266
KeyID: token.ID,
251267
KeyName: token.Name,
252268
ExpiresAt: &expiresAt,
253269
}
254270

271+
// Check if this is an org-scoped or instance-level token
272+
if token.OrganizationID != uuid.Nil {
273+
// Org-scoped token
274+
org, err := uc.orgUseCase.FindByID(ctx, token.OrganizationID.String())
275+
if err != nil {
276+
return nil, fmt.Errorf("finding organization: %w", err)
277+
}
278+
generationOpts.OrgID = &token.OrganizationID
279+
generationOpts.OrgName = &org.Name
280+
} else {
281+
// Instance-level token
282+
generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin)
283+
}
284+
255285
// generate the JWT
256286
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)
257287
if err != nil {
@@ -289,13 +319,15 @@ func WithAPITokenScope(scope APITokenScope) APITokenListOpt {
289319
type APITokenScope string
290320

291321
const (
292-
APITokenScopeProject APITokenScope = "project"
293-
APITokenScopeGlobal APITokenScope = "global"
322+
APITokenScopeProject APITokenScope = "project"
323+
APITokenScopeGlobal APITokenScope = "global"
324+
APITokenScopeInstance APITokenScope = "instance"
294325
)
295326

296327
var availableAPITokenScopes = []APITokenScope{
297328
APITokenScopeProject,
298329
APITokenScopeGlobal,
330+
APITokenScopeInstance,
299331
}
300332

301333
type APITokenListFilters struct {
@@ -318,18 +350,26 @@ func (uc *APITokenUseCase) List(ctx context.Context, orgID string, opts ...APITo
318350
return nil, NewErrValidationStr(fmt.Sprintf("invalid scope %q, please chose one of: %v", filters.FilterByScope, availableAPITokenScopes))
319351
}
320352

321-
orgUUID, err := uuid.Parse(orgID)
322-
if err != nil {
323-
return nil, NewErrInvalidUUID(err)
353+
var orgUUID *uuid.UUID
354+
if orgID != "" {
355+
parsed, err := uuid.Parse(orgID)
356+
if err != nil {
357+
return nil, NewErrInvalidUUID(err)
358+
}
359+
orgUUID = &parsed
324360
}
325361

326-
return uc.apiTokenRepo.List(ctx, &orgUUID, filters)
362+
return uc.apiTokenRepo.List(ctx, orgUUID, filters)
327363
}
328364

329365
func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
330-
orgUUID, err := uuid.Parse(orgID)
331-
if err != nil {
332-
return NewErrInvalidUUID(err)
366+
var orgUUID *uuid.UUID
367+
if orgID != "" {
368+
parsed, err := uuid.Parse(orgID)
369+
if err != nil {
370+
return NewErrInvalidUUID(err)
371+
}
372+
orgUUID = &parsed
333373
}
334374

335375
tokenUUID, err := uuid.Parse(id)
@@ -352,7 +392,7 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
352392
APITokenID: &tokenUUID,
353393
APITokenName: token.Name,
354394
},
355-
}, &orgUUID)
395+
}, orgUUID)
356396

357397
return nil
358398
}

0 commit comments

Comments
 (0)