Conversation
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>
Contributor
AI Session Checks — ⏭️ bypassed by labelAI Coding Session Check BypassedThis PR carries the Learn more about Chainloop Trace. Powered by Chainloop and Chainloop Trace |
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
Member
Author
|
Superseded by #3494: a product token is confined by the project list on its row rather than by memberships. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_tokensrow whosescope, added in the previous PR, isproduct. It is minted with the previous PR'sAPITokenWithScope, which accepts a product scope from this PR on. The projects it reaches are ordinary rows in the existingmembershipstable under a newapi_tokenmember 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_idbeing 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.
CreateconcatenatedorgLevelTokenPolicieswhenever 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.RoleProjectAdmincarriesPolicyAPITokenCreateandPolicyAPITokenRevoke, 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 putsauthorizeResourceback in step withprojectsAllowing.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.
projectsAllowingshort-circuited for every token, reporting a scoped one as unable to act on the very projects it can act on.canCreateContractsInRestrictedModeread "no project" as "organization service account", so a scoped token could create an organization-level contract while the organization restricted that to administrators.authorizeResourcealso dereferencedtoken.ProjectNameunconditionally 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_idclaim mirrors a product-scoped row'sscope_idas defence in depth: compared against the row, never used to grant anything, and preserved acrossRegenerateJWT.Createrefuses the scope combinations that would discard a confinement.SetProjectOwnerno 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.FindByNameInOrgreports 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
mainrather 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-7378AI assistance: written with Claude Code.