Skip to content

feat(rbac): confine a scoped API token to its memberships - #3463

Closed
javirln wants to merge 15 commits into
javier/pfm-7378-api-token-resource-scopefrom
javier/pfm-7378-confine-scoped-tokens
Closed

javirln wants to merge 15 commits into
javier/pfm-7378-api-token-resource-scopefrom
javier/pfm-7378-confine-scoped-tokens

Conversation

@javirln

@javirln javirln commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Issues a CI credential that is valid on exactly the projects a product currently contains, and on no others.

A scoped token is an ordinary api_tokens row whose scope, added in the previous PR, is product. It is minted with the previous PR's APITokenWithScope, which accepts a product scope from this PR on. The projects it reaches are ordinary rows in the existing memberships table under a new api_token member type, written and maintained by the product-membership propagation the Chainloop platform already runs. The control plane reads them through the same middleware, the same context value and the same cache that already serve people, so the authorization path a token takes becomes the one users already take. The memberships cache key is namespaced by member type, because user ids and token ids are both UUIDs drawn from different namespaces.

RBAC keys on the scope kind, never on scope_id being set, since every new token records a scope, and never on whether memberships exist. Deleting a product removes its memberships, so a token whose product is gone has none; deriving the decision from their presence would silently promote it to organization-wide.

Permission ceiling

A scoped token carries exactly the 17 policies a project token carries, never the 6 organization-level ones. Two things stood in the way.

Create concatenated orgLevelTokenPolicies whenever the token was not confined to a project — unconditionally, and even when explicit policies were passed, so a caller asking for one narrow policy received six more, among them minting further tokens and reading every registered integration's configuration.

authorizeResource's membership branch enforced only the membership role, and the attestation endpoints are skipped by the authz middleware. RoleProjectAdmin carries PolicyAPITokenCreate and PolicyAPITokenRevoke, so a platform-written membership could hand back exactly what the token was created without. The token's own ACL is now enforced alongside the role, which also puts authorizeResource back in step with projectsAllowing.

Gate functions

Six needed the membership fall-through, two of which were wrong for a confined caller beyond simply not knowing about the new scope. projectsAllowing short-circuited for every token, reporting a scoped one as unable to act on the very projects it can act on. canCreateContractsInRestrictedMode read "no project" as "organization service account", so a scoped token could create an organization-level contract while the organization restricted that to administrators.

authorizeResource also dereferenced token.ProjectName unconditionally on its refusal path, which is nil for every token not confined to a project — and refusal is the ordinary outcome for a confined credential. Its refusal now names what the token is confined to, and separates a resource outside the token's scope from a permission its role does not carry.

Also

The product_id claim mirrors a product-scoped row's scope_id as defence in depth: compared against the row, never used to grant anything, and preserved across RegenerateJWT. Create refuses the scope combinations that would discard a confinement. SetProjectOwner no longer counts a token membership as the project's owner. Project member listings exclude token memberships in the query, so the total, the pagination window and the results agree. FindByNameInOrg reports an ambiguous match instead of surfacing a raw ORM error. The token created and revoked audit events carry the token's scope, and name it when it is a product.

Project, organization and instance tokens behave identically to today, whether or not they record a scope: every new branch is guarded by a product scope, which no such token carries.

Targets main rather than PR 3's branch, so its diff currently includes PR 3's commit as well; it will shrink to just this change once #3462 merges. Review #3462 first. Part 2 of 2 for https://linear.app/chainloop/issue/PFM-7378

AI assistance: written with Claude Code.

Review in cubic

Issues a CI credential valid on exactly the projects a product currently
contains, and on no others.

A scoped token is an ordinary api_tokens row carrying the scope and
scope_id added in the previous commit. The projects it reaches are ordinary
rows in the existing memberships table under a new api_token member type,
written and maintained by the propagation the Chainloop platform already
runs. The control plane reads them through the same middleware, the same
context value and the same cache that already serve people, so the
authorization path a token takes becomes the one users already take. The
cache key is namespaced by member type, because user ids and token ids are
both UUIDs drawn from different namespaces.

RBAC keys on the scope column, never on whether memberships exist. Deleting
a product removes its memberships, so a token whose product is gone has
none; inferring from their presence would silently promote it to
organization-wide.

The permission ceiling had two holes. Create concatenated
orgLevelTokenPolicies whenever the token was not confined to a project —
unconditionally, and even when explicit policies were passed, so a caller
asking for one narrow policy received six more, among them minting further
tokens. And authorizeResource's membership branch enforced only the
membership role; the attestation endpoints are skipped by the authz
middleware, and RoleProjectAdmin carries PolicyAPITokenCreate and
PolicyAPITokenRevoke, so a platform-written membership could hand back
exactly what the token was created without. The token's own ACL is now
enforced alongside the role, which also puts authorizeResource back in step
with projectsAllowing.

Six gate functions needed the fall-through, not the four anticipated.
projectsAllowing short-circuited for every token, reporting a scoped one as
unable to act on the very projects it can act on. And
canCreateContractsInRestrictedMode read "no project" as "organization
service account", so a scoped token could create an organization-level
contract while the organization restricted that to administrators.

authorizeResource also dereferenced token.ProjectName unconditionally on
its refusal path, which is nil for every token not confined to a project —
and refusal is the ordinary outcome for a confined credential. Its refusal
now names what the token is confined to, and separates a resource outside
the token's scope from a permission its role does not carry.

Also: the product_id claim mirrors scope_id as a cross-check, compared
against the row and never used to grant anything, preserved across
RegenerateJWT; Create refuses the scope combinations that would discard a
confinement; SetProjectOwner no longer counts a token membership as the
project's owner; project member listings exclude token memberships in the
query so the total, the pagination window and the results agree; and
FindByNameInOrg maps an ambiguous match to a validation error instead of a
masked internal one.

Project, organization and instance tokens behave identically to today:
every new branch is guarded by a condition that is empty for every token
that could exist before this change.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
@chainloop-platform

chainloop-platform Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

AI Session Checks — ⏭️ bypassed by label

AI Coding Session Check Bypassed

This PR carries the skip-ai-session label, so the AI coding session check was bypassed.

Learn more about Chainloop Trace.


Powered by Chainloop and Chainloop Trace

@javirln
javirln changed the base branch from main to javier/pfm-7378-api-token-resource-scope September 22, 2026 08:38
authorizeResource capped a resource-scoped token at its own ACL, refusing any
policy the token row did not list. The ACL and RolesMap are separate
vocabularies and neither is a subset of the other: the attestation endpoints
are absent from ServerOperationsMap, so no token ACL has ever carried
workflow_run:create, while RoleProjectAdmin grants it precisely so a member can
attest. The cap therefore denied init, store, cancel, GetUploadCreds and both
attestation-state writes to the very credential the scope exists to issue.

The ceiling now refuses only the organization-level policies, which is the
threat it was added for: RoleProjectAdmin carries PolicyAPITokenCreate and
PolicyAPITokenRevoke, and a scoped token is created with neither. Everything
else is the membership role's to grant. IsOrgLevelTokenPolicy exports that
question next to the list that answers it.

This also drops an api_tokens lookup per call: the enforcer resolved the
api-token subject by loading the row the middleware had already put on the
context.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: ed47378e-60d6-4d5a-91b9-bcbf3ae20c2a
Revoke's guard was rewritten from "the target is not confined to a project" to
"the target is organization-wide". Those were the same thing until a token
could be confined to a resource outside this database, so the rewrite reads as
a rename while silently dropping such targets out of the guard.

Nothing below it can compensate. authorizeResource returns on its first line
for any caller whose RBAC is disabled, which every organization-wide token is,
so the scope branch added to protect these rows cannot refuse the only callers
that reach it. An organization-wide token could therefore revoke any
product-scoped token in its organization: a CI credential destroying
platform-issued ones it cannot even list, since List forces the project scope
for it. Revoking another organization-wide token stayed refused, so the
asymmetry favoured the more privileged target.

The guard keys on the project column again, which covers both kinds of target.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: ed47378e-60d6-4d5a-91b9-bcbf3ae20c2a
The scope's display name goes, so nothing on this side carries it any
more: APITokenWithScope takes the resource kind and id, the context token
drops the field, and a refusal names the scope by id. Resolving an id to a
name is for whoever owns the resource.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
Every token is about to record its scope, whatever it is confined to, so
"has a scope_id" will stop meaning "confined to a product". The scoped
checks now key on the kind: IsResourceScoped means a product scope, and
IsOrgWide follows from it, for both the stored token and the one on the
context. A product claim is only satisfied by a product-scoped row, and
revoking a token authorizes against its product only when it has one.

Tokens that record an organization, project or instance scope therefore
take exactly the path tokens with no scope take today.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

The generic APITokenWithScope replaces this branch's product-only option,
and the product rules move into the shared scope validation, which now
accepts a product scope. The organization-level policies are granted by
the scope kind, so an organization scope named explicitly keeps them.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
An organization token that records its scope is still forced to list
project tokens only, while a product token is not organization-wide: it
keeps the scope it asked for and is narrowed to the projects of its
memberships, down to an empty set, not nil, when it holds none.

The revoke test that re-implemented the guard in its own body goes;
TestRevokeConfinesWhatAnOrgWideTokenCanDestroy drives the real one.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
The APITokenCreated and APITokenRevoked payloads carry the scope and
scope id recorded on the token row, so the audit trail says what a token
reaches. The description names a product scope, the only kind that
changes that reach; for the others it stays as it was, since they follow
from the organization and project the event belongs to. Revocations by
the stale-token sweeper go through the same path and carry it too.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
Both memberships indexes are partial (parent_id IS NULL / IS NOT NULL), so
filtering a token's rows by member type and id alone matched neither and
every request authenticated with a product token seq-scanned the table.
An always-true parent_id predicate lets the planner combine both partial
indexes in a BitmapOr. The listing test now covers an inherited
membership alongside the explicit ones.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
…fm-7378-confine-scoped-tokens

Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

# Conflicts:
#	app/controlplane/internal/service/apitoken.go
#	app/controlplane/internal/service/apitoken_test.go
…ming it

The service layer no longer names the product kind. The token exposes the
resource it is confined to through ResourceScope, and both the listing and
Revoke consume it generically, so the control plane keeps no knowledge of
which kinds live outside its database.

Revoking a resource-scoped token is now covered from a user's side as well:
through the control plane only an organization admin can revoke one, and a
member is refused whatever roles it holds.

Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3
@javirln

javirln commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Superseded by #3494: a product token is confined by the project list on its row rather than by memberships.

@javirln javirln closed this Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant