-
Notifications
You must be signed in to change notification settings - Fork 53
feat(api-token): allow org-level tokens to manage project-scoped tokens #2811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is something that could be done with |
||
| if !req.ProjectReference.IsSet() { | ||
| return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so this special case is needed because we now allow org tokens to pass. |
||
| } | ||
| } | ||
|
|
||
| // 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps we can simplify all of this with a new resource "ProjectApiToken" and enforcing that specific permission. |
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Piskoo marked this conversation as resolved.
|
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,14 @@ type APITokenJWTConfig struct { | |
| SymmetricHmacKey string | ||
| } | ||
|
|
||
| // orgLevelTokenPolicies are additional policies granted only to org-level tokens. | ||
| // They allow managing project-scoped tokens. | ||
| var orgLevelTokenPolicies = []*authz.Policy{ | ||
| authz.PolicyAPITokenCreate, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd call this
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was mentioning this above. We can create this specific resource, and then calling the enforcer directly from the service (instead of checking token!=nil && token.ProjectId == nil ...)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But this one is needed for the middleware to let the request pass. |
||
| authz.PolicyAPITokenList, | ||
| authz.PolicyAPITokenRevoke, | ||
| } | ||
|
|
||
| // APIToken is used for unattended access to the control plane API. | ||
| type APIToken struct { | ||
| ID uuid.UUID | ||
|
|
@@ -204,6 +212,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 = append(policies, orgLevelTokenPolicies...) | ||
| } | ||
|
|
||
| // 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
Uh oh!
There was an error while loading. Please reload this page.