Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e823afa
feat(api-token): record what every new token is scoped to
javirln Sep 29, 2026
41b17fa
feat(api-token): name a token's resource scope by its kind
javirln Sep 29, 2026
0131754
feat(api-token): add the list of projects a product token reaches
javirln Sep 29, 2026
f81f713
feat(api-token): store the projects a product token reaches
javirln Sep 29, 2026
3d599e7
feat(api-token): allow creating a product-scoped token with its projects
javirln Sep 29, 2026
b3e53e0
feat(api-token): carry a token's scope and projects onto the request
javirln Sep 29, 2026
f4bad0c
feat(rbac): confine a product-scoped token to its projects
javirln Sep 29, 2026
6633ac2
fix(rbac): stop an empty visible-project set disabling the project fi…
javirln Sep 29, 2026
935cc1d
feat(api-token): let the owner of a resource scope keep its tokens cu…
javirln Sep 29, 2026
9ebe52b
fix(api-token): refuse an empty policy list for a resource scope
javirln Sep 29, 2026
a0aabdc
feat(audit): record a token's scope on its issuance and revocation
javirln Sep 29, 2026
fe5567f
test(rbac): attest with a product token into its projects only
javirln Sep 29, 2026
ee729a5
test(rbac): pin why a stale project id reaches nothing
javirln Sep 29, 2026
bed91a8
fix(api-token): keep the old scope names as deprecated aliases
javirln Sep 29, 2026
a03ffc7
Merge branch 'javier/pfm-7378-api-token-project-ids' into javier/pfm-…
javirln Sep 29, 2026
d2801c5
fix(api-token): tighten product token validation and docs after review
javirln Sep 29, 2026
06a5984
test(api-token): sharpen product token confinement tests after review
javirln Sep 29, 2026
b06b605
Merge upstream main into javier/pfm-7378-api-token-project-ids
javirln Sep 29, 2026
7d3b63c
Merge branch 'javier/pfm-7378-api-token-project-ids' into javier/pfm-…
javirln Sep 29, 2026
7390e91
fix(api-token): require an organization for project tokens and org li…
javirln Sep 30, 2026
a74e2e7
Merge branch 'javier/pfm-7378-api-token-project-ids' into javier/pfm-…
javirln Sep 30, 2026
89ab0d3
fix(rbac): keep product tokens from creating organization contracts v…
javirln Sep 30, 2026
549cfde
refactor(api-token): keep token scope rules in the application, not i…
javirln Sep 30, 2026
6a543ea
Merge branch 'javier/pfm-7378-api-token-project-ids' into javier/pfm-…
javirln Sep 30, 2026
e0e5090
test(api-token): say which scope rules Create adds over the repositor…
javirln Sep 30, 2026
ad1d1ea
Merge branch 'javier/pfm-7378-api-token-project-ids' into javier/pfm-…
javirln Sep 30, 2026
33e37b0
Merge upstream main into javier/pfm-7378-confine-product-tokens
javirln Sep 30, 2026
f372387
chore(rbac): drop self-reminder comments from the token confinement code
javirln Sep 30, 2026
b3c9795
refactor(api-token): derive legacy scopes and widen ResourceScope
javirln Sep 30, 2026
23c4be2
refactor(api-token): drop the product_id JWT claim
javirln Sep 30, 2026
4a6df6f
refactor(api-token): name the bulk token setters by the tokens they t…
javirln Sep 30, 2026
ea3c256
refactor(api-token): compare token scopes to the product kind explicitly
javirln Sep 30, 2026
8491169
refactor(api-token): read the JWT scope claim as an instance-admin flag
javirln Sep 30, 2026
c88e71f
fix(api-token): let an organization-wide token reach every project
javirln Sep 30, 2026
bacfea5
test(api-token): name the Authorization header once in the middleware…
javirln Sep 30, 2026
15d329d
refactor(api-token): leave bulk updates of product tokens to the plat…
javirln Sep 30, 2026
64859ce
refactor(api-token): tell an instance token by its row, not by the sc…
javirln Sep 30, 2026
c6bcb5f
fix(api-token): refuse a product token with an empty policy list
javirln Sep 30, 2026
ebd001e
test(audit): record the scope in the existing API token event goldens
javirln Sep 30, 2026
3ce3044
refactor(rbac): let an organization token pass a project check under …
javirln Sep 30, 2026
f91ac04
refactor(api-token): rename IsResourceScoped to IsProductScoped
javirln Sep 30, 2026
a544b74
feat(api-token): backfill the scope of tokens from before the scope c…
javirln Sep 30, 2026
9afbc80
refactor(api-token): read a token's reach from its scope alone
javirln Sep 30, 2026
5a82adb
refactor(api-token): read a token's scope straight off its row
javirln Sep 30, 2026
c495750
fix(api-token): let an unconfined caller revoke a token that records …
javirln Sep 30, 2026
2e39627
refactor(api-token): validate a token's scope in one place and classi…
javirln Sep 30, 2026
36b0927
test(api-token): fold duplicate scope tests and drop stale ones
javirln Sep 30, 2026
a63f0a7
test(api-token): exercise the backfill repair and the scope id the mi…
javirln Sep 30, 2026
6020ed0
chore: record the AI coding sessions for this branch
javirln Oct 1, 2026
0299393
refactor(api-token): name org and instance tokens apart instead of or…
javirln Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions app/controlplane/internal/service/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ 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 {
// Org-level and instance API tokens can only create project-scoped tokens
if token := entities.CurrentAPIToken(ctx); token.IsOrgScoped() || token.IsInstanceScoped() {
if !req.ProjectReference.IsSet() {
return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens")
}
Expand Down Expand Up @@ -108,9 +108,10 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR
defaultProjectFilter = []uuid.UUID{project.ID}
}

// Org-level API tokens can only see project-scoped tokens
// Org-level and instance API tokens can only see project-scoped tokens. A product token is
// neither: it is narrowed to its projects by the filter above instead.
scope := mapTokenScope(req.Scope)
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
if token := entities.CurrentAPIToken(ctx); token.IsOrgScoped() || token.IsInstanceScoped() {
scope = authz.ResourceTypeProject
}

Expand Down Expand Up @@ -165,22 +166,31 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev
return nil, errors.NotFound("not found", "API token not found")
}

// 1 - Only admins can manage global contracts
if t.ProjectID == nil && rbacEnabled(ctx) {
// 1 - Only admins can manage organization and instance tokens
if (t.IsOrgScoped() || t.IsInstanceScoped()) && rbacEnabled(ctx) {
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 {
// An organization or instance token may only revoke project tokens
if token := entities.CurrentAPIToken(ctx); token.IsOrgScoped() || token.IsInstanceScoped() {
if t.ProjectID == nil {
return nil, errors.Forbidden("forbidden", "org-level API tokens cannot revoke org-level tokens")
return nil, errors.Forbidden("forbidden", "org-level API tokens can only revoke project-scoped 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 {
return nil, err
// Make sure the caller has permission to revoke the token where it lives
if !t.IsOrgScoped() && !t.IsInstanceScoped() {
kind, id, ok := t.ResourceScope()
Comment thread
jiparis marked this conversation as resolved.
switch {
case ok:
if err := s.authorizeResource(ctx, authz.PolicyAPITokenRevoke, kind, id); err != nil {
return nil, err
}
case t.Scope == nil && !rbacEnabled(ctx):
// A row recording no scope reaches nothing, so revoking it only takes it away: a
// caller RBAC does not narrow may do so.
default:
return nil, errors.BadRequest("invalid", "this API token carries an incomplete scope and cannot be managed here")
}
}

Expand Down Expand Up @@ -213,12 +223,15 @@ func apiTokenBizToPb(in *biz.APIToken) *pb.APITokenItem {
res.LastUsedAt = timestamppb.New(*in.LastUsedAt)
}

if in.ProjectID != nil {
res.ScopedEntity = &pb.ScopedEntity{
Type: string(authz.ResourceTypeProject),
Id: in.ProjectID.String(),
Name: *in.ProjectName,
// A token reports what it is confined to. A product lives outside this database, so its id
// stands in for its name.
if kind, id, ok := in.ResourceScope(); ok {
name := id.String()
if kind == authz.ResourceTypeProject && in.ProjectName != nil {
name = *in.ProjectName
}

res.ScopedEntity = &pb.ScopedEntity{Type: string(kind), Id: id.String(), Name: name}
}

return res
Expand Down
131 changes: 63 additions & 68 deletions app/controlplane/internal/service/apitoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) {

svc := &APITokenService{service: newService()}

orgID := uuid.New()
ctx := context.Background()
ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString()})
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), ProjectID: nil})
ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: orgID.String()})
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID})

req := &pb.APITokenServiceCreateRequest{Name: "test-token"}

Expand All @@ -54,7 +55,7 @@ func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) {
func TestAPITokenServiceListForcesProjectScopeForOrgTokens(t *testing.T) {
t.Parallel()

orgID, projectID := uuid.New(), uuid.New()
orgID, projectID, productID := uuid.New(), uuid.New(), uuid.New()

testCases := []struct {
name string
Expand All @@ -66,18 +67,40 @@ func TestAPITokenServiceListForcesProjectScopeForOrgTokens(t *testing.T) {
}{
{
name: "an organization token is forced to project tokens",
caller: &entities.APIToken{ID: uuid.NewString()},
caller: &entities.APIToken{ID: uuid.NewString(), Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID},
wantScope: authz.ResourceTypeProject,
},
{
name: "an organization token asking for global tokens is still forced",
caller: &entities.APIToken{ID: uuid.NewString()},
caller: &entities.APIToken{ID: uuid.NewString(), Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID},
requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL,
wantScope: authz.ResourceTypeProject,
},
{
// Not organization-wide either, and confined to nothing: narrowed to no project.
name: "a token recording no scope is narrowed to no project",
caller: &entities.APIToken{ID: uuid.NewString()},
requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL,
wantScope: authz.ResourceTypeOrganization,
wantProjects: []uuid.UUID{},
},
{
// Not organization-wide, so not forced: it is narrowed to its projects instead.
name: "a product token keeps the scope it asks for, narrowed to its projects",
caller: &entities.APIToken{ID: uuid.NewString(), Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, ProjectIDs: []uuid.UUID{projectID}},
requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL,
wantScope: authz.ResourceTypeOrganization,
wantProjects: []uuid.UUID{projectID},
},
{
// Empty, not nil: nil would mean RBAC does not narrow this caller at all.
name: "a product token reaching nothing is narrowed to no project",
caller: &entities.APIToken{ID: uuid.NewString(), Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, ProjectIDs: []uuid.UUID{}},
wantProjects: []uuid.UUID{},
},
{
name: "a project token keeps the scope it asks for",
caller: &entities.APIToken{ID: uuid.NewString(), ProjectID: &projectID},
caller: &entities.APIToken{ID: uuid.NewString(), ProjectID: &projectID, Scope: biz.ToPtr(authz.ResourceTypeProject), ScopeID: &projectID},
requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL,
wantScope: authz.ResourceTypeOrganization,
wantProjects: []uuid.UUID{projectID},
Expand Down Expand Up @@ -121,62 +144,9 @@ func TestAPITokenServiceListForcesProjectScopeForOrgTokens(t *testing.T) {
}
}

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
}

// A listing reports the project a token is confined to, and the scope columns change nothing
// about it: every new token records a scope, yet each one lists exactly as it did before.
// A listing reports what a token is confined to: its project by name, or its product by id. A
// token acting for its whole organization or instance reports nothing, whether or not its row
// records a scope.
func TestAPITokenBizToPbScopedEntity(t *testing.T) {
t.Parallel()

Expand All @@ -193,9 +163,20 @@ func TestAPITokenBizToPbScopedEntity(t *testing.T) {
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
ProjectID: &projectID, ProjectName: biz.ToPtr("billing"),
Scope: biz.ToPtr(authz.ResourceTypeProject), ScopeID: &projectID,
},
want: &pb.ScopedEntity{Type: string(authz.ResourceTypeProject), Id: projectID.String(), Name: "billing"},
},
{
// The product's name is not known to the control plane, so its id stands in for it.
// Without its own branch a product token would read as organization-wide.
name: "a product-scoped token reports its product by id",
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, ProjectIDs: []uuid.UUID{projectID},
},
want: &pb.ScopedEntity{Type: string(authz.ResourceTypeProduct), Id: productID.String(), Name: productID.String()},
},
{
name: "a scope id without a kind is not reported",
token: &biz.APIToken{
Expand All @@ -205,8 +186,7 @@ func TestAPITokenBizToPbScopedEntity(t *testing.T) {
want: nil,
},
{
// New tokens record their scope for every kind, but only a product is reported
// from it: an organization token lists exactly as it did before.
// An organization token is confined to no resource it could report.
name: "an organization-scoped token reports none",
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
Expand All @@ -215,19 +195,34 @@ func TestAPITokenBizToPbScopedEntity(t *testing.T) {
want: nil,
},
{
name: "a project-scoped token still reports its project from project_id",
name: "a project id without a scope is not reported",
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
ProjectID: &projectID, ProjectName: biz.ToPtr("billing"),
Scope: biz.ToPtr(authz.ResourceTypeProject), ScopeID: &projectID,
},
want: &pb.ScopedEntity{Type: string(authz.ResourceTypeProject), Id: projectID.String(), Name: "billing"},
want: nil,
},
{
name: "an organization-level token reports none",
name: "a token recording no scope reports none",
token: &biz.APIToken{ID: uuid.New(), CreatedAt: &createdAt},
want: nil,
},
{
name: "an instance-scoped token reports none",
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
Scope: biz.ToPtr(authz.ResourceTypeInstance),
},
want: nil,
},
{
name: "a product scope missing its id is not reported",
token: &biz.APIToken{
ID: uuid.New(), CreatedAt: &createdAt,
Scope: biz.ToPtr(authz.ResourceTypeProduct), ProjectIDs: []uuid.UUID{projectID},
},
want: nil,
},
}

for _, tc := range testCases {
Expand Down
Loading
Loading