Skip to content

feat(controlplane): sign the API token scope and check it against the row - #3530

Draft
javirln wants to merge 11 commits into
mainfrom
javier/pfm-7580-api-token-jwt-does-not-contain-the-scope-type-and-scope-id
Draft

javirln wants to merge 11 commits into
mainfrom
javier/pfm-7580-api-token-jwt-does-not-contain-the-scope-type-and-scope-id

Conversation

@javirln

@javirln javirln commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Every API token JWT now signs the token's scope type and scope ID. The control plane refuses a token when its database row disagrees with these signed claims. Before this change, the row alone decided the scope of a token. A wrong write to the row could then widen the token, or move it to another project or organization.

Behaviour

  • New claims. Create and RegenerateJWT add the scope_type and scope_id claims to every token. Instance tokens keep the scope: INSTANCE_ADMIN claim, because the platform and older control planes read it.
  • Consistent claims. The token builder refuses to sign claims that contradict each other. For example, it refuses the instance-admin claim on an organization scope.
  • Row check. Both API-token entry points compare the row with the signed claims: the scope, the organization, the project and the workflow. Any difference refuses the token. The control plane logs it as a security event, with the token ID and without the raw JWT.
  • Row-only values. Revocation, policies and the project list of a product token still come only from the row. These values change during the life of a token.
  • Older tokens. Organization, project, workflow-pinned and instance tokens from before this change keep working. The control plane derives their scope from the claims that they already carry. The control plane refuses a product token from before this change, and its owner must create a new token.
  • Regeneration. RegenerateJWT refuses a row that records no scope, or a row whose scope contradicts its other columns.
  • Attestation path. The attestation entry point now loads and checks the token row once. It builds the robot account from the checked row.

Deploying

  • Release the platform with this control-plane library and this control-plane image together. The platform creates product tokens with the library that it pins. A control plane with this change refuses the product tokens that an older library creates.
  • A rollback is safe. Older control planes ignore the new claims.

Known limits

  • The check does not protect against a database compromise. A database writer still controls memberships, policies and the project lists of product tokens.
  • An older product token and an older organization token carry the same claims. If a wrong write changes a product row to an organization scope, the older JWT passes as an organization token. This limit ends when no older tokens remain.
  • RegenerateJWT still signs a row that is consistent but wrong.
  • Until the platform authenticator calls VerifyClaims, the platform endpoints check product and project tokens against the row only.

Part of https://linear.app/chainloop/issue/PFM-7580

AI assistance: written with Claude Code.

Review in cubic

javirln added 11 commits October 5, 2026 16:23
Mutation check (each condition disabled in turn, the package tests fail):
- agreesWith instance-admin check: TestGenerateJWT/the_instance-admin_claim_on_an_organization_scope, TestSignedScope
- agreesWith workflow-needs-project: TestSignedScope/a_workflow_claim_without_a_project_claim
- agreesWith instance names no org/project: TestSignedScope/an_instance_scope_naming_an_organization
- agreesWith organization: TestGenerateJWT/an_organization_scope_naming_another_organization, TestSignedScope
- agreesWith project: TestGenerateJWT/a_project_scope_naming_another_project, TestSignedScope
- agreesWith product: TestSignedScope/a_product_scope_with_a_project_claim
- namedScope ScopeID != "" check: TestSignedScope/an_instance_scope_naming_a_resource
- SignedScope call in GenerateJWT: TestGenerateJWT inconsistent-scope cases

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
Mutation check: disabling each guard in VerifyClaims fails at least one case. nil claims: 'no claims'; no scope: 'a row recording no scope'; legacy product: 'a product token minted before the scope claims'; scope comparison: the 'widened'/'made an instance row'/'moved to another ...' cases; organization: 'a product row moved to another organization'; project: 'the project claim disagrees with the row's project column'; workflow: 'a workflow claim on a row with no workflow'; SignedScope error: 'malformed scope claims' and 'an instance-admin claim on an organization row'. Dropping only the scope id comparison fails the 'moved to another project/product/organization' cases. Gap: flipping sameScopeID's nil branch to return true is not caught by any case (one-nil/one-set ids with equal kinds are unexercised).

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
…generation

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
…signed claims

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
…d claims

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
…verified API token

Load and verify the token row once on the attestation path, add CustomClaims.HasScopeClaims, and share one entry-point harness across the middleware tests.

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
Assisted-by: Claude Code
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
…lish

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

Chainloop-Trace-Sessions: 796bcbff-1898-452d-aaeb-b5311bffdfa6
@chainloop-platform

chainloop-platform Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

AI Session Checks — 🟢 92% · ⚠️ 1 failing

Avg score Sessions Failing policies Attribution Files Lines Total Duration
🟢 92% 1 ⚠️ 1 100% AI / 0% Human 9 +1458 / -522 2h47m16s

🟢 92% — 100% AI — ⚠️ 1 policies failing

Oct 5, 2026 13:10 UTC · 2h47m16s · $110.94 · 2.1k in / 1.3M out · claude-code 2.1.289 (claude-opus-5-5)

View session details ↗

Change Summary

  • Adds signed scope_type and scope_id claims to API token JWTs and typed claim decoding.
  • Refuses tokens whose stored row disagrees with their signed claims on API and attestation paths.
  • Expands auth tests, regression checks, and comment/PR text around the change.

AI Session Overall Score

🟢 92% — Well-planned auth work, strong verification, and no meaningful misalignment or trust erosion.

AI Session Analysis Breakdown

🟢 96% · context-and-planning

🟢 Plan, security review, and approval landed before the multi-file auth edits began. · High Impact

🟢 94% · verification

🟢 The AI reran targeted, differential, mutation, and whole-suite tests before preparing the PR. · High Impact

🟢 92% · alignment

🟢 It honored the user's no-PR constraint until the user later requested draft-PR work. · High Impact

🟢 92% · solution-quality

No notes.

🟢 90% · scope-discipline

No notes.

🟢 89% · user-trust-signal

No notes.


File Attribution

████████████████████ 100% AI / 0% Human

Status Attribution File Lines
modified ai app/controlplane/internal/usercontext/apitoken_middleware_integration_test.go +375 / -76
modified ai app/controlplane/internal/usercontext/apitoken_middleware_test.go +224 / -137
modified ai app/controlplane/pkg/jwt/apitoken/apitoken_test.go +211 / -88
modified ai app/controlplane/pkg/jwt/apitoken/apitoken.go +204 / -44
modified ai app/controlplane/pkg/biz/apitoken.go +143 / -39
modified ai app/controlplane/internal/usercontext/apitoken_middleware.go +77 / -99
modified ai app/controlplane/pkg/biz/apitoken_verify_claims_test.go +123 / -7
modified ai app/controlplane/pkg/biz/apitoken_integration_test.go +97 / -28
modified ai app/controlplane/pkg/usercontext/entities/apitoken.go +4 / -4

Policies (4, 1 failing)

Status Policy Material Messages
✅ Passed ai-config-ai-agents-allowed ai-coding-session-796bcb -
✅ Passed ai-config-no-dangerous-commands ai-coding-session-796bcb -
⚠️ Failed ai-config-no-secrets ai-coding-session-796bcb Secret (generic-password) detected in session content [turn=816, source=tool_result, line=5]: deployment/chainloop/templates/controlplane/secret-config.yaml:16:{{- $hmacpass := include "common.secrets.[REDACTED:generic-password]s.manage" (dict "secret" (include "chainloop.controlplane.fullname...
✅ Passed ai-config-mcp-servers-allowed ai-coding-session-796bcb -

Powered by Chainloop and Chainloop Trace

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant