feat(authz): kernel token verification, human principals & sign-out-everywhere (ARN-255 steps 1 + 3-kernel) - #407
Draft
rita-aga wants to merge 11 commits into
Draft
feat(authz): kernel token verification, human principals & sign-out-everywhere (ARN-255 steps 1 + 3-kernel)#407rita-aga wants to merge 11 commits into
rita-aga wants to merge 11 commits into
Conversation
…ARN-255) The kernel gains a self-contained ES256 (ECDSA P-256) JWT verifier: the security core of RFC-0002 step 1, letting the platform verify tokens minted by an allowlisted issuer instead of trusting self-declared identity headers. - RustCrypto p256 (no ring); ES256-only, alg=none/HS*/RS* rejected before any key is consulted (alg-confusion safe). - Time (exp/nbf) validated against a caller-supplied now_unix from sim_now(), never a wall clock — DST-deterministic, no JWT library clock involved. - 16 adversarial unit tests: valid verify, alg=none, alg confusion, wrong key, tampered payload, expired, not-yet-valid, leeway, wrong iss/aud, aud array, unknown/ambiguous kid, malformed shapes. Wiring into the resolver + the TrustedIssuer registry follow in this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…lver (ARN-255) Wires the ES256 verifier into the bearer path: - TrustedIssuer IOA spec (per-tenant allowlist entry; entity id = issuer URL; inline JWKS so verification makes no outbound call and stays deterministic; governed Register/RotateKeys/Suspend/Resume/Revoke actions) + CSDL + bootstrap registration alongside AgentCredential. - IdentityResolver::resolve routes JWS-shaped bearers to issuer verification and opaque bearers to the AgentCredential registry; both share the cache, with JWT entries capped at the token's own exp. - ResolvedIdentity widened with acting_for (owning human), auth_generation (carried for the step-3 revocation check), and from_jwt. - SecurityContext::from_verified_jwt maps verified claims to a principal with acting_for and role; odata bindings select it for JWT-resolved identities. Additive: header and AgentCredential paths are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…latformState Integration test seeds a TrustedIssuer with a real P-256 JWKS through the normal dispatch path, mints ES256 tokens, and drives IdentityResolver::resolve: valid token -> verified contributor agent acting for the human; rogue-key, expired, unregistered-issuer, tampered, and suspended-issuer tokens all reject. Documents the tenant-policy prerequisite in the live shell smoke script. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…vation (ARN-255 step 3)
Kernel half of RFC-0002 step 3, plus finishing step 1's activation path.
- PrincipalGeneration entity (IOA + CSDL + bootstrap): a kernel-side monotonic
generation counter per principal subject, advanced by BumpGeneration. This is
option A — the generation lives in the kernel, not on an app entity like
Member, so 'sign out everywhere' is a first-class platform capability the
kernel can enforce generically. The resolver rejects any token whose
auth_generation is older than the subject's current generation (keyed on the
human sub, so signing out a human also kills agents acting for them).
- Human principals: a verified token with a sub but no agent_type now resolves
to a Customer principal carrying its role claim (for Cedar); a token with an
agent_type stays an Agent acting for the human. Bindings select the kind.
- bootstrap_trusted_issuer_from_env: a deployment activates the JWT path by
setting TEMPER_TRUSTED_ISSUER_{URL,JWKS,AUD}, mirroring how TEMPER_API_KEY
becomes the operator credential — no authenticated API call, no hand-seeded
Cedar policy needed to get started.
Integration tests (8 total, all against a real PlatformState): human->Customer
with role; bump-generation invalidates older tokens while a token minted at the
new generation still resolves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…RN-255 step 4) IOA actions can declare authorization requirements, compiled to enforceable Cedar at app install: - requires_role = ["owner","curator"] -> forbid Publish unless principal.role in [...] - requires = "creator" -> forbid Withdraw unless resource.creator_sub == principal.id Pieces: - temper-spec: Action gains optional requires_role / requires (parser + struct; unknown-key-tolerant, so existing specs are unaffected). - temper-authz: generate_authz_overlays emits forbid-unless overlays with Cedar string escaping + entity-ident validation (closes the ARN-172 injection class the generators lacked). Forbid overlays compose on top of an app's existing permit-all base (Cedar forbid-overrides-permit), so this needs no default-deny migration and no dependency on the ARN-230 fallback fix. - temper-platform: os-app install compiles each parsed automaton's annotations and appends the overlays to the tenant's Cedar (durable via policy rows). Verified end to end: TOML annotation -> parse -> overlay -> real AuthzEngine denies wrong-role/non-creator and permits owner/curator/creator on a permit-all base. 6 new tests (2 compiler-unit incl. injection, 2 engine-enforcement, 2 platform parse->overlay); temper-spec 267 + temper-authz 74 green; clippy -D clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…il-open (review P1s) From the fresh-context reviews of ARN-255: - CRITICAL: TrustedIssuer + PrincipalGeneration were HTTP-exposed with mutating actions and no Cedar gate — an agent could RegisterIssuer with its own JWKS and mint owner tokens (full authz takeover), or BumpGeneration to sign out arbitrary users. Add system-platform forbid overlays gating RegisterIssuer/ RotateIssuerKeys/Suspend/Resume/Revoke/BumpGeneration to System||Admin, and make permissive() ALSO merge the system-platform policy so the gate holds even under the ARN-230 permit-all fail-open. Platform seeding (env bootstrap + tests) now dispatches as System. - Sign-out fail-open: the generation check ran only when the token carried an auth_generation claim, so a claim-less token was permanently revocation-exempt. Treat a missing claim as generation 0 and always compare — a single BumpGeneration now invalidates it. New engine test proves agent/customer are forbidden from the registry even on a permit-all base; 75 authz + 8 integration + 19 identity tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
…cation in the kernel Decision (Rita, 2026-07-25): option B — spec-declared authorization is removed. The spec format is moving off TOML soon, so TOML-coupled authz sugar would be rewritten at that migration; hand-written .cedar is format-independent and stays the single place authorization is written. Reviews also found the annotation contract was wrong in both directions (requires_role forbids role-less service principals, so it could not express even the RFC's own Publish example; requires="creator" compared principal.id, which for an agent is its client_id, so an agent could never act for its owner). Authorization-in-spec is deferred to the design of the new spec language, where it can be expressive rather than sugar. The removed code remains in this branch's history. Backed out: Action.requires_role/requires (spec + parser), generate_authz_overlays and its escaping helpers, the os-app install generation seam, and their tests. Kept and extended — the identity core, which is independent of the compiler: - Grant liveness at the kernel (review P1): a revoked agent grant kept full OData access until its token expired, because only the MCP front door checked it. The resolver now rejects a token whose grant_id has been bumped, so revocation takes effect at the kernel. New integration test covers it. 9 integration + 75 authz + spec/platform suites green.
Boots a real temper server, activates a trusted issuer through the same environment configuration a deployment uses, mints real ES256 tokens with the matching key, and drives the real HTTP surface. This became runnable once the issuer could be activated from the environment: registering one over HTTP is admin-gated, so a bare tenant refused it and the check could not get off the ground. 9/9 locally: valid token authenticates (200); rogue-key, expired, unregistered-issuer and garbage tokens rejected (401); the operator key still works, so the change is additive (200); and a verified agent token cannot register an issuer, rotate issuer keys, or bump a generation (403) — the takeover and sign-out-DoS paths are closed at the real HTTP surface, not only in unit tests.
Collaborator
Author
|
@greptile review |
…ction Three independent reviews (Claude, Greptile, Codex) ran against this branch. Codex found two exploitable holes the others missed, both rooted in the same blind spot: every gate here named bound actions, while OData authorizes PATCH/PUT/DELETE as the lowercase `update`/`delete` actions. - The identity-entity gate listed RegisterIssuer/RotateIssuerKeys/... only, so a contributor could PATCH TrustedIssuer.jwks_json with action `update`, install its own signing key and mint owner tokens — the exact takeover the gate was written to stop, reachable through a different verb. Both forbids are now resource-wide (any action) unless System or Admin. The live e2e missed it because it too probed only bound actions; the kernel test now covers create/read/update/delete explicitly. - Verified JWT identity was only constructed for bound actions. The shared read/create/update/delete helper always used the agent-only constructor, turning a verified human into an Agent with an empty type and dropping role and acting_for — so a read and a bound action evaluated different principals for the same token. There is now one authoritative conversion, ResolvedIdentity::to_security_context, used by every entry point. Also from Greptile: - Generation reads treated every failure as "generation 0", so a revoked token was accepted during an actor timeout. A missing entity already materialises at 0, so Err now means deny: revocation checks fail closed. - The trusted issuer was registered for tenant `default` only, while agent specs install for every tenant; issuer-signed tokens would 401 elsewhere. It now registers for each tenant that receives the agent specs. 67 authz + 9 integration + 19 identity tests green; clippy -D warnings clean.
…suer rows Two P0s from the third independent review, both verified live against a real server with the app's own Cedar bundle loaded. 1. The previous commit's resource-wide forbid locked out the caller that has to do the writing. The authorization server reaches the kernel with the shared API key, which resolves through the bootstrapped operator credential to Agent::"operator" — neither System nor Admin — and no app policy grants these entities at all, so even the Admin branch hit default-deny. In production that meant: token issue and refresh 500, grant revocation left the kernel still honouring a revoked agent, sign-out-everywhere failed on its first statement, and (with the session check now failing closed) every user would have been signed out permanently. The system-platform policy now permits, and exempts from the forbid, the operator credential alongside System and Admin. This weakens nothing: a holder of the shared key can already self-declare admin at the ingress. Tested against a production- shaped policy set rather than the permissive engine, which is what hid it. 2. `iss` is attacker-chosen and read before any signature check, and entity reads spawn the entity — so presenting any junk token persisted an Active TrustedIssuer row with empty fields, unauthenticated, for any string the caller chose. That is storage growth, an audit surface reading as "Active trusted issuers", and one missing field away from a trust bypass. Both resolver lookups now check existence first and never materialise a row; an absent generation counter correctly reads as never-revoked. 11 integration + 68 authz tests green, including the two new negative proofs.
…work done Codex, a second independent Codex Sol session, and an independent Fable — plus Greptile on the PR. In this effort each caught a different class the others missed: bypass surface, fail-open behaviour, and whether the thing actually runs. Two agreeing is not a reason to skip the third. Also records the lesson that let two P0s hide here: a gate proven against a permissive engine, and an end-to-end that only exercised the named action, were not proof. Test the production shape and probe the generic verbs.
Collaborator
Author
|
@greptile review |
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.
What
The kernel can now verify ES256 JWTs from a per-tenant allowlisted issuer and resolve them to a verified principal — the keystone of RFC-0002 (First-Class Authorization), rollout step 1. Additive: the existing header and
AgentCredentialpaths are unchanged.Pieces
crates/temper-server/src/identity/jwt.rs): RustCryptop256(pure Rust, no ring). Pinned toES256—none/HS256/RS256are rejected before any key is consulted, so alg-confusion can't select a different primitive.exp/nbfare validated against a caller-suppliednow_unixfromsim_now(), never a wall clock, so verification is DST-deterministic. 17 adversarial unit tests.RegisterIssuer/RotateIssuerKeys/SuspendIssuer/ResumeIssuer/RevokeIssueractions.identity/resolver.rs): JWS-shaped bearers → issuer verification (cache TTL capped at the token's ownexp); opaque bearers → theAgentCredentialregistry as before.SecurityContext::from_verified_jwt: maps verified claims to a principal carryingacting_for(the owning human) androle; odata bindings pick this constructor for JWT-resolved identities.Verification
cargo test -p temper-server --lib identity::— 19 pass;temper-authz66 pass;temper-platformbootstrap 33 pass. Full four-gate pre-push pipeline (fmt, clippy, readability, complete test suite) passed.scripts/e2e-trusted-issuer.shboots a local server, registers a TrustedIssuer, mints real ES256 tokens, and asserts valid-accepted / rogue-key-rejected / expired-rejected / unregistered-issuer-rejected / operator-key-still-works. (Live run attached on this PR once I've run it against the built binary.)Follow-ups (not in this PR)
auth_generationclaim is carried throughResolvedIdentity, but the check needs a store the kernel can read (Member is an app entity). Decision — kernel-side generation entity vs. app liveness hook — lands with step 3.Part of ARN-255.
🤖 Generated with Claude Code
https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR
Greptile Summary
Adds tenant-scoped ES256 JWT identity resolution and authorization principals.
Confidence Score: 2/5
The PR is not safe to merge until JWT revocation is made fail-closed, cache invalidation is coordinated with revocation actions, and issuer bootstrap covers enabled non-default tenants.
Cached JWT principals can outlive issuer suspension and generation bumps, generation lookup failures accept revoked tokens, and deployment-configured issuers are unavailable to non-default tenants.
Files Needing Attention: crates/temper-server/src/identity/resolver.rs, crates/temper-platform/src/identity_cache.rs, crates/temper-cli/src/serve/bootstrap.rs
Security Review
Two authentication revocation paths remain bypassable: cached identities are not invalidated when issuer or generation state changes, and generation-read failures are treated as an unrevoked generation.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant Middleware as Bearer middleware participant Resolver as IdentityResolver participant Issuer as TrustedIssuer participant Generation as PrincipalGeneration participant Authz as Cedar/OData Client->>Middleware: Bearer JWT + tenant Middleware->>Resolver: resolve(tenant, token) alt Cached identity Resolver-->>Middleware: ResolvedIdentity else Cache miss Resolver->>Issuer: Read tenant issuer and JWKS Issuer-->>Resolver: Status, audience, keys Resolver->>Resolver: Verify ES256, iss, aud, exp, nbf Resolver->>Generation: Read human and grant generations Generation-->>Resolver: Current counters Resolver-->>Middleware: Verified ResolvedIdentity end Middleware->>Authz: Trusted SecurityContext Authz-->>Client: Authorized OData responseComments Outside Diff (1)
crates/temper-server/src/identity/resolver.rs, line 106-108 (link)When an issuer is suspended, its keys are rotated, or a principal or grant generation is bumped, this cache hit returns the previously resolved identity before any liveness checks run. Because JWT-related mutations do not invalidate the shared resolver cache, the revoked token continues authorizing OData operations for up to 60 seconds.
How this was verified: The cache-hit branch returns before
resolve_jwt, and the mutation invalidation middleware covers onlyAgentCredentialactions.Knowledge Base Used: temper-server
Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "test(authz): live end-to-end against a r..." | Re-trigger Greptile
Context used (5)