feat(admin)!: sign read keys in to a read-only dashboard - #541
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a read-only tier for admin sessions, allowing read-only keys to sign in to the dashboard with restricted privileges. The GET /admin/api/session endpoint now returns the session's access tier (read or write), which the frontend uses to conditionally render write affordances. Server-side mutations continue to enforce write permissions, returning a 403 Forbidden for read-only sessions. Documentation and tests have been updated globally to reflect this change. The review feedback suggests adding a defensive fallback to 'read' access in the frontend session initialization to ensure type safety and prevent unauthorized write access if the tier is undefined.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Sessions recorded no privilege, so `authenticate` returned `Write` for every cookie and `login_submit` had to refuse `read_keys` outright: while a session carried full access unconditionally, minting one from a read key would have escalated it. That left read-key operators with curl and no dashboard at all. `SessionStore` now records the tier the minting credential carried and `authenticate` reads it back, so the login form accepts a read key and mints a read session. Every mutation still goes through `require_write` -- that gate, not the dashboard's good manners, is what makes the session read-only, and all seven mutation routes were re-checked to still carry it. OIDC keeps minting write sessions: nothing in its allow lists distinguishes a read-only identity, so deriving one there would invent a policy the config cannot express. `GET /admin/api/session` now carries `access`, which the dashboard renders its write affordances from. It is a display signal only. The read-key test's login half changes with the behavior it covers, from "a read key is refused a cookie" to the stronger property that replaces it: the read session reaches a mutation with a valid cookie, a matching CSRF token and a same-origin request, and is still refused. BREAKING CHANGE: `POST /admin/login` answers `303` with a session cookie for a `[server.admin] read_keys` credential where it previously answered `401`. The session is read-tier and every mutation still answers `403`, so the cookie carries no permission the read key did not already have through the header slot -- but it carries its own lifetime. Browser sessions are validated against the in-memory session store alone, so rotating a compromised read key now stops its header credential at the next reload while its cookie goes on reading the admin surface until `session_ttl_secs` (default 1h) elapses; restart rather than reload to drop it. A deployment that treated "read keys cannot open a browser session" as a revocation property no longer has it. Dropping stale sessions on reload is tracked in #100, which this widens from write-tier credentials to both tiers.
…n tier `GET /admin/api/session` now reports whether the session may mutate, so the bundle no longer offers actions that will come back `403`. A read session keeps every table it can read and loses the two add-account forms, the row actions, and the actions column those buttons lived in -- dropped rather than left standing, since a header row one cell wider than its body rows is a rendering bug, not a cosmetic one. It also gets a sentence saying why, because a section that merely lost its buttons reads as a broken page. `useCanWrite` is the single place the tier is interpreted, and it compares against `write` rather than against `read`: an unrecognized value then reads as read-only instead of unlocking the page. This hides affordances and protects nothing -- `require_write` on the server is the enforcement. The suite asserts both tiers against the same fixtures. A read-only assertion alone would pass just as well against a dashboard that had lost its write affordances for everyone, so each property is pinned from both directions; the harness accordingly defaults to a write session, which is the page every suite written before the tier existed was asserting against.
…sion Two surfaces asserted what the previous commits changed, in every locale, and both are now false: `read_keys` "cannot sign in: `POST /admin/login` rejects it with `401`", and `/admin/api/session` returns "the two per-session values". The sweep was column-wise rather than by sample -- the claim lives in the config reference, the endpoints reference and the remote-provisioning guide, each in en/ko/ja/zh-cn, plus `m9-admin-surface.md` and the `read_keys` doc comment in `src/config.rs`. The READMEs needed no change: "read-tier serves the GETs" is still true of the spend row they carry it on. `admin-ui-delivery.md` gains Decision 6, which fixes the stack above Resolution 2's React + Vite: Base UI for primitives, TanStack Router landing with the deep links Resolution 6 already promised, and Tailwind v4 + shadcn/ui conditional on `html.rs` generating its stylesheet instead of hand-keeping a copy of `index.css`. Cloudflare Kumo is rejected with its reasons. Two constraints bound the whole table and both are already in this document -- the bundle ships inside the binary, and the shell's CSP has no `'unsafe-inline'` for styles, which refuses a CSS-in-JS runtime outright. It also records what the stack does *not* decide, because naming one would otherwise obscure it: monitoring is blocked on `storage.md` (every observable is a point-in-time value and `metrics.rs` emits to sinks, not a scrape endpoint), and access-permission management is display-only until the same decision, since a UI cannot mint a key that the config refuses as a literal.
Opening the login form to `read_keys` put read-tier credentials inside a gap the rotation note only described for admin tokens: sessions are validated against the in-memory store alone, so a rotated key's cookie keeps reading until `session_ttl_secs`. The note's closing sentence was already true in the abstract and vacuous for read keys, since a read key could not hold a session at all -- it is now load-bearing for them, and a reader deciding whether a read key is safe to hand out widely needs it spelled out. What survives is read-only and strictly less than the full access a write-tier session already carried across the same window, so this widens #100's scope rather than adding a defect it does not already track. Found by an adversarial review of this branch.
90f3fc3 to
a995ffc
Compare
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a995ffc07d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 28 files
Architecture diagram
sequenceDiagram
participant Browser
participant AdminAPI as Admin API Server
participant SessionStore as SessionStore
participant Auth as AdminAuth
participant Config as Config
participant Dashboard as Dashboard UI
Note over Browser,Dashboard: Admin Dashboard Session Flow with Access Tiers
Browser->>AdminAPI: POST /admin/login (token)
AdminAPI->>Auth: login_access(token)
Auth->>Config: authenticate_value(token)
Config-->>Auth: Option<AdminAccess> (Read or Write)
Auth-->>AdminAPI: access tier
alt Valid credential
AdminAPI->>SessionStore: create(ttl, access)
SessionStore-->>AdminAPI: (sid, csrf)
AdminAPI-->>Browser: 303 + Set-Cookie (shunt_admin_session=sid)
else Invalid token
AdminAPI-->>Browser: 401
end
Note over Browser,Dashboard: Session Bootstrap - Dashboard Render
Browser->>AdminAPI: GET /admin/api/session (cookie)
AdminAPI->>SessionStore: lookup(sid)
alt Valid session
SessionStore-->>AdminAPI: SessionAuth { csrf, access }
AdminAPI-->>Browser: { csrf, expiry_buffer_ms, access }
Browser->>Dashboard: Render with access tier
alt access = write
Dashboard->>Dashboard: Show add forms, row actions, actions column
else access = read
Dashboard->>Dashboard: Hide add forms, row actions, actions column
Dashboard->>Dashboard: Show read-only notice message
end
else Invalid/expired
AdminAPI-->>Browser: 401
end
Note over Browser,AdminAPI: Mutation Attempt (enforcement stays server-side)
Browser->>AdminAPI: POST /admin/api/accounts/claude (cookie + CSRF + same-origin)
AdminAPI->>SessionStore: lookup(sid)
SessionStore-->>AdminAPI: SessionAuth { csrf, access }
AdminAPI->>AdminAPI: require_write(access)
alt access = write
AdminAPI-->>Browser: 200 Success
else access = read
AdminAPI-->>Browser: 403 Forbidden
end
Note over Browser,AdminAPI: OIDC Login (always mints write session)
Browser->>AdminAPI: GET /admin/oidc/callback (OIDC authorize)
AdminAPI->>AdminAPI: Validate OIDC allowlist
AdminAPI->>SessionStore: create(ttl, AdminAccess::Write)
SessionStore-->>AdminAPI: (sid, csrf)
AdminAPI-->>Browser: 303 + Set-Cookie (write-tier session)
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
`readJson` casts the response body rather than validating it, so the `access` field reaching `Session` is asserted to be an `AdminAccess`, never checked. The declared type says `'read' | 'write'`; a response without the field puts `undefined` behind it. Defaulting to the *lower* tier is what keeps that mismatch harmless, and the direction is the whole point -- the page then hides its write affordances rather than unlocking them. This changes no behavior today: `useCanWrite` compares against `'write'`, so `undefined` already read as read-only, and an unrecognized value still does without needing a default. What it buys is the runtime value matching its declared type, so a future guard written as `access !== 'read'` -- the natural spelling, and the one that would silently escalate -- starts from a value that is always one of the two. Reported by gemini-code-assist on #541.
…ated Opening `POST /admin/login` to `read_keys` falsified a claim that was mirrored across six places, and the previous commits updated only the published docs and the `m9` tier bullet. The source-level contracts still said the opposite, which is the worse half: a maintainer reads the Rustdoc, not the site. - `src/admin/mod.rs` module doc claimed the read tier is refused "on every mutation, the login form included". - The `session_bootstrap` Rustdoc still called its response "the two per-session values"; it has returned three since `access` was added. - `m9-admin-surface.md` asserted in three further places that login needs a write-tier credential and that "a session therefore always carries write access". - `m4-inbound-auth.md` and `src/auth/slots.rs` carry the same sentence about non-header accept channels, and both named a write-tier form credential via `authenticate_login_token` -- a function this branch renamed to `login_access`. Neither bot flagged those two; they turned up only on a column-wise sweep for the claim rather than for the files under review. The emergency-rotation limitation also gains the bound Greptile supplied: `session_ttl_secs` is a bare `u64` with a default and `Config::validate` does not range-check it, so the window a rotated read key's cookie survives is whatever the deployment set, not the 1h default. Reported by chatgpt-codex-connector, cubic-dev-ai and greptile-apps on #541.
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ed3a96077
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The header-slot enumeration describes `cookie` as an accept slot to justify stripping it on every forward, and every copy called what it accepts a **write-tier** `shunt_admin_session`. A `read_keys` login now mints a read-tier session, so the qualifier is false wherever it appears. The strip itself is unchanged and still correct -- widening what `session_cookie` accepts cannot break `strip ⊇ accept` when the whole header is removed. This is the enumeration's accuracy, not its behavior. Both bots pointed at one instance each; grepping for the *term* rather than for the files under review found six, in two languages of contract: - `docs/m4-inbound-auth.md` (the `cookie`-strip paragraph, which contradicted the accept-slot table two rows above after the previous commit corrected it) - `src/auth/slots.rs` twice -- the module enumeration and the `RESERVED_SLOTS` rationale -- and the comment on `no_forward_site_relays_the_admin_session_cookie` - `site/.../guides/inbound-codex-endpoint.md` and `guides/shared-gateway.mdx`, each with its ko/ja/zh-cn copies, since the claim is user-facing there Reported by cubic-dev-ai and chatgpt-codex-connector on #541.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2e62f6a1b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The remote-provisioning guide walks an operator through the Claude and Codex tables and says every row offers **Re-login**, and that `imported` rows also carry **Refresh**. A read-tier session renders neither -- `useCanWrite` gates the row-action cell, its header, and both add-account forms -- so the guide described a page a `read_keys` operator does not see. Both passages now say so, and the table walkthrough closes by naming what a read session gets instead: the same tables and the same Status column, minus the row actions and the add forms, because `require_write` refuses those mutations server-side anyway. ko/ja/zh-cn carry the same two passages and are updated with it, per AGENTS.md. Reported by chatgpt-codex-connector on #541.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 95486a8f7a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`POST /admin/login` is a navigation form POST, so it carries no `x-csrf-token` and had no same-origin guard -- unlike `logout` three functions below, whose comment already spells out why that combination needs one. `SameSite=Strict` decides whether the browser *sends* an existing session cookie; it does not stop the browser storing the `Set-Cookie` a cross-site form submission gets back. So anyone holding a valid credential could submit that form from their own page and replace a visitor's session with one of their choosing. That asymmetry cost nothing while only write keys could sign in: a write-key holder replacing an operator's cookie gains nothing they do not already have. Opening the form to `read_keys` gave it a point. A read key goes to someone deliberately granted less privilege, and this was its one lever against a write operator -- silently downgrading that dashboard to read-only, with every write failing `403` until they signed in again. The guard runs before the rate limiter, so a cross-site flood cannot spend the operator's login budget. Scripted logins are unaffected: `same_origin` returns `true` when neither `Sec-Fetch-Site` nor `Origin` is present. The test asserts both tiers -- the guard is about the request's origin, not about privilege, and one that refused only read keys would pass a read-only test. Verified falsifiable: removing the guard fails it on the cross-origin assertion rather than on a compile error. Also fixes the README capability row, which listed only `tokens_env`, `tokens_file` and `write_keys` as bringing the dashboard up. A `read_keys`-only `[server.admin]` now brings it up read-only, so the row said a supported configuration was unavailable. Updated in all four READMEs. Reported by chatgpt-codex-connector on #541.
There was a problem hiding this comment.
All reported issues were addressed across 11 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 606c146f6f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`access` became a third value `GET /admin/api/session` returns, but the component's header comment still described the response as the CSRF token and the refresh buffer. It now names all three and what each is for, including that a `read_keys` login means a cookie no longer implies write. `ui/README.md` already listed the three; this was the source-level copy. Reported by chatgpt-codex-connector on #541.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4b9e63f7b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Conflict in `tests/admin_surface.rs`, in the read-key login test both sides changed. Resolved by keeping this branch's assertions and adopting main's mechanism: - main (#542) replaced raw `std::env::set_var`/`remove_var` with the `common::EnvVars` RAII guard, which restores on drop, and added `tests/env_lock_coverage.rs` — a structural gate that fails on any remaining raw call. The test header merged to the guard cleanly; the two trailing `remove_var` calls inside the conflicted block are dropped as the guard now covers them. They were cleanup, not preconditions, so nothing is asserted less (the distinction #542's own review round turned on). - main's side ended with a plain "the write key does log in" check. This branch's write half supersedes it: it asserts the same login plus the tier the session reports and that the same mutation is *not* refused, which is what keeps the read-half assertions non-vacuous. - This branch's cross-origin login assertions are kept in full. Verified on the merged tree: 2582 passed, 0 failed; fmt, clippy, site build (161 pages), nimbus-docs lint (94 files), UI typecheck and 74 UI tests all green.
|



Summary
Two changes that belong together: the admin surface learns that a browser session has a tier, and the design record fixes the frontend stack the dashboard grows into next.
A read key can now use the dashboard.
SessionStorerecords the privilege of the credential that minted a session, andauthenticatereads it back instead of returningWritefor every cookie. That removes the reasonlogin_submithad to refuseread_keysoutright — while a session carried full access unconditionally, minting one from a read key would have silently escalated it. A read-key operator previously had curl and no dashboard at all.Enforcement does not move: every mutation still goes through
require_write, and all seven mutation routes were re-checked to still carry that gate. OIDC keeps minting write sessions, because nothing in its allow lists distinguishes a read-only identity.One consequence is a revocation caveat, not a permission one (see Notes). The cookie carries no permission the read key did not already have through the header slot, but it carries its own lifetime: sessions are validated against the in-memory store alone, so a rotated read key's cookie keeps reading until
session_ttl_secs. That is #100's existing gap widened from write-tier credentials to both tiers, and it is documented inm9-admin-surface.mdand in theBREAKING CHANGE:footer.The dashboard renders from the tier.
GET /admin/api/sessioncarriesaccess, and the bundle stops offering actions that would come back403: a read session keeps every table it can read and loses the two add-account forms, the row actions, and the actions column those lived in — dropped rather than left standing, since a header row one cell wider than its body rows is a rendering bug. It also gets a sentence saying why, because a section that merely lost its buttons reads as broken.useCanWritecompares againstwriterather than againstread, so an unrecognized value reads as read-only rather than unlocking the page.Decision 6 in
admin-ui-delivery.mdfixes what goes inside Resolution 2's React + Vite: Base UI for primitives, TanStack Router landing with the/admin/*deep links Resolution 6 already promised, and Tailwind v4 + shadcn/ui conditional onhtml.rsgenerating its stylesheet instead of hand-keeping a copy ofindex.css. Cloudflare Kumo is rejected with reasons. It also records what the stack does not decide — monitoring is blocked onstorage.md, and access-permission management is display-only until the same decision, since a UI cannot mint a key the config refuses as a literal.Milestone / spec
docs/m9-admin-surface.md— the access-tier section (read/write) and the emergency-rotation limitationdocs/admin-ui-delivery.md— Decision 5 (session bootstrap) and the new Decision 6Checklist
cargo buildpassescargo testpasses (new behavior is covered; tests run without network/loopback where possible)cargo clippy --all-targets -- -D warningscleancargo fmt --all --checkcleansrc/admin/mod.rs(2193) andsrc/config.rs(9265) were already over; this change adds 41 and 4 lines respectively and splits neitherdocs/updated if this change deviates from itNotes for reviewers
The credential path is the thing to look at closely. The safety argument is that the tier now reaches the session and
require_writereads it, so please check the audit rather than taking it on faith:Authenticated::Sessionis constructed in exactly one place (src/admin/mod.rs), nothing infers write fromauthok.kind, and all seven mutation routes (4 Claude + 3 Codex) callrequire_write. The spend surface is unaffected — it authenticates header credentials only, through its ownauthenticateinsrc/gateway/spend/api.rs.An adversarial review (GPT-6 Astra) raised one finding, and it stands: read-key sessions outlive revocation of the key that minted them.
authenticatevalidates against the session store without rechecking the originating credential, andAppState::refreshedcarries the store across reloads, so overwriting a${file:}-backed read key and reloading stops its header credential while its cookie reads on for up tosession_ttl_secs(default 1h). This is not a gap this PR invents —m9-admin-surface.mdalready documented it and #100 already tracks the fix — but it previously applied only to credentials that could log in, which read keys could not. What survives is read-only, and strictly less than the full access a write-tier session already carried across the same window. The response here is to name it (rotation note +BREAKING CHANGE:footer) rather than to bind sessions to a credential generation in this PR: fixing it for read keys while leaving the worse write-tier case unfixed would be the odd half-state, and #100 is where it belongs for both.Both test halves are load-bearing. The read assertions alone would also pass against a dashboard that refused every cookie, so each property is pinned from both directions, and the UI harness defaults to a write session so the 57 pre-existing tests keep asserting on the page they were written against. Both suites were checked falsifiable by mutation: re-hardcoding
access: AdminAccess::Writefails the Rust test with an assertion (not a compile error), and makinguseCanWritereturntruefails 3 of the 5 new UI tests.Docs were swept column-wise, not by sample. The
read_keyslogin claim lived in the config reference, the endpoints reference and the remote-provisioning guide, each in en/ko/ja/zh-cn, plusm9-admin-surface.mdand thesrc/config.rsdoc comment. The READMEs needed no change — "read-tier serves the GETs" is still true of the spend row they carry it on.sitebuilds clean (161 pages) andnimbus-docs lintreports 94 files clean.Still open for a reviewer to weigh in on: whether an operator wants a read-only dashboard reachable at all, given the revocation caveat above. The tier is opt-in by config — a deployment with no
read_keyssees no behavior change at all — but a deployment that hands read keys out widely is the one that should read the rotation note before upgrading.