feat(api): require a live session for writes and add a write rate bucket - #2091
feat(api): require a live session for writes and add a write rate bucket#2091ka1kqi wants to merge 6 commits into
Conversation
Reads keep the CLI JWT's offline verification, but writes are
higher-stakes: surface the JWT's sid claim through _verify_access_jwt
onto AuthResult.session_id and add a require_live_session dependency
that checks the sid against the live session row via a new internal
validate-session-live route — revoking the session now takes effect
instantly on writes. Session-token credentials skip the hop (their
introspection already proved liveness), and any introspection ambiguity
fails closed with a 503. The liveness route speaks a {live: boolean}
envelope, so it gets a dedicated fail-closed caller instead of the
valid-discriminator helper. Also adds the write rate bucket (key_write +
a write tier launching with the read numbers) for the upcoming public
write routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
PR overviewThis pull request requires access tokens to carry a session ID, checks JWT session liveness before account writes, and adds a separate write rate-limit bucket and internal liveness endpoint. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Scanned with Semgrep · TruffleHog · Trident review. View in Trident Fixed/addressed: 1 · PR risk: 0/10 |
There was a problem hiding this comment.
1 issue found across 6 files
Confidence score: 3/5
backend/rest/routers/public/deps.py: The new liveness guard is not attached to any write route, so revoked JWT sessions can still perform writes. Applyrequire_live_sessionto every intended user-authenticated write route and verify the protected route coverage.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/rest/routers/public/deps.py">
<violation number="1" location="backend/rest/routers/public/deps.py:973">
P1: The new liveness guard is dead in production: no write route depends on it, so revoked JWT sessions are not checked before writes. Attach `require_live_session` to every intended user-authenticated write route and ensure its auth dependency carries the JWT session id.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as Public API Client
participant API as Public REST API
participant Auth as Auth Dependencies
participant JWKS as JWKS Endpoint
participant UI as Traceroot UI Internal API
participant DB as Session Database
participant Redis as Rate Limit Store
Note over Client,Redis: Public request runtime flow
alt Read request with CLI access JWT
Client->>API: GET read route with Authorization: Bearer JWT
API->>Auth: Verify JWT offline
Auth->>JWKS: Resolve signing key by kid
JWKS-->>Auth: EdDSA public key
Auth-->>API: AuthResult with user identity
API->>Redis: Check read bucket
Redis-->>API: Allow or rate-limit decision
API-->>Client: Read response or 429 with Retry-After and X-RateLimit-* headers
else Write request with CLI access JWT
Client->>API: POST/PUT/DELETE write route with Authorization: Bearer JWT
API->>Auth: Verify JWT offline and extract sub plus sid
Auth->>JWKS: Resolve signing key by kid
JWKS-->>Auth: EdDSA public key
Auth->>UI: POST /api/internal/validate-session-live with sessionId and X-Internal-Secret
UI->>DB: Find session by id and select expiresAt
DB-->>UI: Session row or no row
UI-->>Auth: 200 {live: true} or {live: false}
alt Session is live
Auth->>Redis: Check dedicated write bucket
Redis-->>API: Allow or rate-limit decision
API-->>Client: Write response or 429 with Retry-After and X-RateLimit-* headers
else Session revoked or expired
Auth-->>API: 401 Session revoked or expired
API-->>Client: 401 Unauthorized
else Liveness response is unavailable or malformed
Auth-->>API: 503 Authentication service error
API-->>Client: 503 Service Unavailable
end
else Session-token or API-key credential
Client->>API: Write request with session token or API key
API->>Auth: Introspect or validate credential
Auth-->>API: AuthResult with no session_id
Note over Auth: Existing introspection has already established liveness, no liveness hop
API->>Redis: Check dedicated write bucket
Redis-->>API: Allow or rate-limit decision
API-->>Client: Write response or 429 with rate-limit headers
end
Note over API,Redis: Rate-limit identity is stamped from workspace, user, and plan.
Note over API,Redis: Write bucket keys use rl:write:<plan>:<workspace/user scope>, account stamping currently resolves callers to free.
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| return live | ||
|
|
||
|
|
||
| async def require_live_session(auth: _AccountAuth) -> None: |
There was a problem hiding this comment.
P1: The new liveness guard is dead in production: no write route depends on it, so revoked JWT sessions are not checked before writes. Attach require_live_session to every intended user-authenticated write route and ensure its auth dependency carries the JWT session id.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/rest/routers/public/deps.py, line 973:
<comment>The new liveness guard is dead in production: no write route depends on it, so revoked JWT sessions are not checked before writes. Attach `require_live_session` to every intended user-authenticated write route and ensure its auth dependency carries the JWT session id.</comment>
<file context>
@@ -879,3 +906,98 @@ async def authenticate_and_stamp_account_caller(request: Request, auth: _Account
+ return live
+
+
+async def require_live_session(auth: _AccountAuth) -> None:
+ """Block a write when the JWT's minting session has been revoked or expired.
+
</file context>
The write path checks the token's sid against the live session row and skips the check when it is absent, so a signed token carrying no sid wrote past a revoked session until it expired. Verification now rejects a token without a usable sid instead of degrading it to None: the sole issuer sets it on every token it mints, so nothing legitimate loses access. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
…quires it Rejecting sid-less access JWTs left the read-path test fixtures minting tokens the verifier no longer accepts, failing three JWT-read tests on this branch. Stamp a sid in both read fixtures' _mint_jwt helpers, matching what the issuer actually mints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Part 5 of the public write paths. Stacked on the dashboard/widget PR.
Writes check that the caller's session is still live before proceeding — reads stay offline-verifiable on the JWT alone, but a revoked session must not keep writing until its token expires. Adds a dedicated internal liveness route and a separate rate-limit bucket for writes.
Note:
_PLAN_LIMITS_WRITEdeclares starter/pro/enterprise tiers that are currently unreachable — the account stamper hardcodes the free plan, so every caller caps at the free limit. Worth fixing separately.Part of #2010.
🤖 Generated with Claude Code
https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Summary by cubic
Part of the public write paths (#2010). Writes now verify the caller's session is still live before proceeding — reads stay offline-verifiable on the JWT alone, but a revoked session must not keep writing until its token expires.
sidclaim and rejects any token without a usable one, so the write path always has a session to check. This also applies to reads, since both paths share the verifier; safe because the only issuer setssidon every token it mints.validate-session-liveroute answers{live: boolean}; any ambiguity (network error, non-200, malformed body) fails closed with a 503._PLAN_LIMITS_WRITEdeclares starter/pro/enterprise tiers that are currently unreachable — the account stamper hardcodes the free plan, so every caller caps at the free limit.Written for commit bd7bd78. Summary will update on new commits.
Update: access tokens must now carry a
sidA review bot found a fail-open on the revocation control: a JWT with no
sidclaimdegraded to
None, and the liveness check returns early when there is no session tocheck — so a token without one could write past a revoked session.
_verify_access_jwtnow rejects an absent, empty, or non-string
sidwith a 401 instead of degrading.This tightening applies to reads as well as writes, because both paths share the
verifier. That is safe rather than a behavior risk: the only issuer sets
sidfrom thesession's primary key on every token it mints, and access tokens are short-lived, so no
legitimate caller can be holding a sid-less one for long. It fails closed.
Update: adversarial-review fixes
Test fixtures for CLI-JWT reads now mint a
sid, keeping this PR green at its own tip after the sid-required verification change.