Skip to content

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
mainfrom
claude/arn-255-kernel-token-verification
Draft

feat(authz): kernel token verification, human principals & sign-out-everywhere (ARN-255 steps 1 + 3-kernel)#407
rita-aga wants to merge 11 commits into
mainfrom
claude/arn-255-kernel-token-verification

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

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 AgentCredential paths are unchanged.

Pieces

  • ES256 verifier (crates/temper-server/src/identity/jwt.rs): RustCrypto p256 (pure Rust, no ring). Pinned to ES256none/HS256/RS256 are rejected before any key is consulted, so alg-confusion can't select a different primitive. exp/nbf are validated against a caller-supplied now_unix from sim_now(), never a wall clock, so verification is DST-deterministic. 17 adversarial unit tests.
  • TrustedIssuer governed entity (IOA spec + CSDL + bootstrap): per-tenant allowlist, entity id = the issuer URL, inline JWKS so verification makes no outbound call and stays deterministic; RegisterIssuer/RotateIssuerKeys/SuspendIssuer/ResumeIssuer/RevokeIssuer actions.
  • Resolver (identity/resolver.rs): JWS-shaped bearers → issuer verification (cache TTL capped at the token's own exp); opaque bearers → the AgentCredential registry as before.
  • SecurityContext::from_verified_jwt: maps verified claims to a principal carrying acting_for (the owning human) and role; odata bindings pick this constructor for JWT-resolved identities.

Verification

  • cargo test -p temper-server --lib identity:: — 19 pass; temper-authz 66 pass; temper-platform bootstrap 33 pass. Full four-gate pre-push pipeline (fmt, clippy, readability, complete test suite) passed.
  • scripts/e2e-trusted-issuer.sh boots 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)

  • Step 2 (katagami adapter forwards the caller's token) ships in parallel; the header-trust removal is gated on that + zero header-authenticated traffic (see RFC rollout + ARN-170).
  • Sign-out-everywhere: the token's auth_generation claim is carried through ResolvedIdentity, 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.

  • Introduces inline-JWKS TrustedIssuer and PrincipalGeneration governed entities.
  • Maps verified human and agent JWT claims into Cedar SecurityContext values.
  • Adds issuer lifecycle, generation-based sign-out and grant-revocation checks.
  • Adds startup issuer registration plus unit, integration, and live E2E coverage.

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

Filename Overview
crates/temper-server/src/identity/jwt.rs Adds a narrowly scoped ES256 verifier with explicit algorithm, signature, issuer, audience, and time validation.
crates/temper-server/src/identity/resolver.rs Adds tenant issuer lookup and JWT claim resolution, but cached identities and generation-read errors can bypass revocation.
crates/temper-authz/src/context.rs Adds trusted JWT construction for human and agent principals, including role and acting-for data.
crates/temper-authz/src/engine/mod.rs Adds built-in forbids protecting issuer and principal-generation actions from non-admin principals.
crates/temper-platform/src/bootstrap.rs Registers the new governed specs and provides environment-driven issuer seeding.
crates/temper-cli/src/serve/bootstrap.rs Activates environment issuer bootstrap, but only for the default tenant.
crates/temper-platform/tests/trusted_issuer_resolve.rs Covers cryptographic and revocation behavior with fresh resolvers, leaving the shared-cache revocation path untested.

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 response
Loading

Comments Outside Diff (1)

  1. crates/temper-server/src/identity/resolver.rs, line 106-108 (link)

    P1 security Cached identities bypass revocation

    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 only AgentCredential actions.

    Knowledge Base Used: temper-server

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-server/src/identity/resolver.rs
    Line: 106-108
    
    Comment:
    **Cached identities bypass revocation**
    
    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 only `AgentCredential` actions.
    
    **Knowledge Base Used:** [temper-server](https://app.greptile.com/arni-labs/-/custom-context/knowledge-base/nerdsane/temper/-/docs/temper-server.md)
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/temper-server/src/identity/resolver.rs:106-108
**Cached identities bypass revocation**

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 only `AgentCredential` actions.

### Issue 2 of 3
crates/temper-server/src/identity/resolver.rs:305
**Generation reads fail open**

When the generation lookup encounters an actor timeout, full mailbox, missing transition table, or other query error, this arm substitutes generation zero. A token invalidated by a human sign-out or grant revocation is therefore accepted during the failure and becomes an authenticated OData principal.

**How this was verified:** `get_tenant_entity_state` returns these operational failures as `Err`, and both revocation checks trust the zero returned here.

### Issue 3 of 3
crates/temper-cli/src/serve/bootstrap.rs:493
**Issuer bootstrap skips other tenants**

When the server hosts an app tenant or runs in tenant-routed mode, this hardcoded `default` registration leaves that tenant without the configured issuer. Bearer resolution performs the `TrustedIssuer` lookup in the request tenant, causing otherwise valid issuer-signed JWTs for every non-default tenant to receive 401 responses.

Reviews (1): Last reviewed commit: "test(authz): live end-to-end against a r..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used (5)

rita-aga and others added 3 commits July 18, 2026 19:42
…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
@rita-aga rita-aga changed the title feat(authz): kernel verifies platform-issued JWTs (ARN-255 step 1) feat(authz): kernel token verification, human principals & sign-out-everywhere (ARN-255 steps 1 + 3-kernel) Jul 19, 2026
rita-aga and others added 5 commits July 19, 2026 12:21
…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.
@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread crates/temper-server/src/identity/resolver.rs Outdated
Comment thread crates/temper-cli/src/serve/bootstrap.rs Outdated
rita-aga added 3 commits July 25, 2026 17:08
…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.
@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

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