feat(security): owner-only API key rotation with live session kill#3233
Merged
Conversation
…e session kill Closes the leaked-key revocation gap from RBAC M3/M6 (#3054). Editing `users[].api_key_hash` via update_user (or by hand in config.toml) only becomes effective after a daemon restart because `AuthState.user_api_keys` is a frozen `Arc<Vec<…>>` snapshot. A leaked per-user bearer token stays valid until the operator restarts the daemon — exactly the scenario rotation needs to defeat. Changes ------- 1. `POST /api/users/{name}/rotate-key` — owner-only (covered by the existing `is_owner_only_write` allowlist for `/api/users/*` non-GET). Generates a fresh 32-byte hex plaintext key, hashes with Argon2id, persists via the existing `persist_users` flow, and returns the plaintext exactly once. The plaintext is never logged or echoed again — `RoleChange` audit entry records actor + target only. 2. `AuthState.user_api_keys` is now `Arc<RwLock<Vec<ApiUserAuth>>>` (was `Arc<Vec<…>>`) so the in-memory snapshot can be swapped live. The same Arc is shared with `AppState.user_api_keys` so any user mutation can refresh it. `persist_users` now rebuilds the snapshot after the kernel reload, which fixes the same latent bug for `update_user` / `delete_user` / `import_users` along the way. 3. Dashboard wiring — `rotateUserKey()` API client, `useRotateUserKey()` mutation hook, "Rotate API key" button on `UsersPage`, and a copy-once display modal that makes it crystal clear the plaintext won't be shown again. Session-invalidation strategy ----------------------------- `SessionToken` (the value stored in `AppState.active_sessions`) does not carry a `user_id` — the active-sessions store is the dashboard login pool tied to the single shared `dashboard_user`/`dashboard_pass` credential pair, not per-user API keys. Per-user bearer tokens never land in `active_sessions` (middleware.rs:626 vs :612). Swapping the `user_api_keys` snapshot is what kills the rotated user's bearer token, and that completes on the very next request after rotation. Tests ----- * `users_rotate_key_returns_new_plaintext` — happy path * `users_rotate_key_persists_new_hash` — on-disk round-trip * `users_rotate_key_invalidates_existing_session` — pre/post snapshot verifies that the OLD plaintext stops authenticating against the live AppState; the NEW plaintext starts authenticating * `users_rotate_key_unknown_user_404` * `users_rotate_key_preserves_other_fields` — RBAC M3 policy + bindings + role survive rotation (regression cover for the M5/M3 preserve pattern, mirrored from the existing update_user tests) * `users_rotate_key_admin_returns_403` (api_integration_test) — full-stack owner-only gate, including a self-rotate denial
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…ation # Conflicts: # crates/librefang-api/dashboard/src/api.ts # crates/librefang-api/tests/api_integration_test.rs
…ation # Conflicts: # crates/librefang-api/tests/api_integration_test.rs
OpenAPI Drift CI failed because openapi.json already had the rotate-key endpoint but the 4 generated SDK clients (Go / JS / Python / Rust) hadn't been regenerated. Reproduced locally with `python3 scripts/codegen-sdks.py` — pure additive diffs, no manual changes.
…ation # Conflicts: # crates/librefang-api/dashboard/src/lib/http/client.ts # crates/librefang-api/dashboard/src/lib/mutations/users.ts # crates/librefang-api/src/routes/users.rs # crates/librefang-api/tests/api_integration_test.rs # crates/librefang-api/tests/users_test.rs
Deploying librefang-docs with
|
| Latest commit: |
ec6d5f9
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://417c4c13.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-rbac-api-key-rotation.librefang-docs-web.pages.dev |
…ation # Conflicts: # crates/librefang-api/dashboard/src/pages/UsersPage.tsx # crates/librefang-api/tests/users_test.rs
houko
added a commit
that referenced
this pull request
Apr 26, 2026
…tribution (#3250) Merge race between #3236 (loopback attribution) and #3233 (rotate-key race lock) left main with a rustc error: `auth_state.user_api_keys` became `Arc<tokio::sync::RwLock<Vec<ApiUserAuth>>>` which has no `.iter()` method. The earlier in-handler snapshot (line ~363) clones into a local `user_api_keys: Vec<ApiUserAuth>` exactly so downstream code doesn't have to re-acquire the lock — line ~429 just needs to read from that snapshot the same way line ~757 already does. Pure rename. No behavior change.
houko
added a commit
that referenced
this pull request
Apr 26, 2026
#3233 added Arc<RwLock<Vec<ApiUserAuth>>> user_api_keys to AppState. The new audit_test_app_state helper landed before that merged so its struct-literal init was missing the field. CI flagged E0063 on macOS and Windows after rebase. Initialize as empty Vec — these tests don't exercise auth at all, they call auth_denied directly with synthesized extensions.
houko
added a commit
that referenced
this pull request
Apr 26, 2026
…tive deny + memory audit emit (#3205) (#3249) * fix(security): RBAC M3 follow-up — namespace traversal + case-insensitive deny + memory audit emit Three reviewer-flagged bugs in PR #3205 (RBAC M3): #7 — Namespace path traversal (REAL): `UserMemoryAccess::can_read/can_write` deferred to the generic `capability::glob_matches`, where `*` greedily matches any text including path separators. So a pattern of `kv:user_*` matched `kv:user_../admin` or `kv:user_evil/etc/passwd` — letting a memory tool that builds the namespace from user-controlled input cross into another user's bucket. Fix: introduce `namespace_glob_matches` with two stricter rules: 1. Reject any candidate containing a `..` segment (delimited by `/`, `:`, or whitespace) outright. 2. `*` may not span `/` or `:` separators inside a longer pattern (e.g. `kv:user_*` only matches a single component after the prefix). `*` standalone still matches any non-traversing namespace so the owner / admin "see everything" UX is preserved. Kept separate from the generic `glob_matches` (which is used for tool-name and capability matching where collapsing across separators is desirable). #8a — Case-sensitive deny lists (PARTIAL → fixed): `UserToolPolicy`, `ChannelToolPolicy`, and `UserToolCategories` matched tool names case-sensitively, so a deny rule for `shell_exec` would not catch a hallucinated `SHELL_EXEC` invocation. Built-in tools dispatch by exact case so the call would have failed downstream anyway, but MCP / skill tool providers may accept their own case variants and the deny list is supposed to be the authoritative gate. ASCII-lowercased both pattern and tool name at every per-user check site. Limited to per-user policy layers — the rate-limit buckets and capability matchers continue using exact-case comparison since tool identities there are content-addressed. #8b — Memory ACL denial silently dropped from audit chain (REAL): `routes/memory.rs::auth_denied` returned a 403 without recording a `PermissionDenied` row, while the parallel `routes/audit.rs`, `routes/budget.rs`, `routes/authz.rs`, and the global auth middleware all emit one. A privilege probe against `/api/memory*` was therefore invisible to an admin reading `/api/audit`. Fix: extended `auth_denied` to take `&AppState` + `&Extensions` and emit a `PermissionDenied` audit row before returning 403. Anonymous callers are recorded with `user_id = None`; authenticated-but-denied callers carry their attributed `user_id` plus user / role in the detail. Tests added: - `memory_access_namespace_blocks_path_traversal` — `kv:user_../admin`, `kv:user_alice/../bob`, and separator-crossing globs all denied. - `memory_access_star_pattern_still_rejects_traversal` — even `["*"]` rejects `..` candidates. - `user_deny_is_case_insensitive`, `channel_deny_is_case_insensitive`, `categories_deny_is_case_insensitive` — pin lowercase normalisation. - `auth_denied_emits_audit_row_for_anonymous` and `auth_denied_emits_audit_row_for_authenticated_user` — pin the audit-emit contract for both anonymous and attributed denials. * fix(test): add user_api_keys field to memory.rs audit_test_app_state #3233 added Arc<RwLock<Vec<ApiUserAuth>>> user_api_keys to AppState. The new audit_test_app_state helper landed before that merged so its struct-literal init was missing the field. CI flagged E0063 on macOS and Windows after rebase. Initialize as empty Vec — these tests don't exercise auth at all, they call auth_denied directly with synthesized extensions.
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.
Closes the leaked-key revocation gap from RBAC M3/M6 (#3054). Editing
users[].api_key_hashviaupdate_user(or by hand inconfig.toml) only becomes effective after a daemon restart today, becauseAuthState.user_api_keysis a frozenArc<Vec<…>>snapshot built once at boot. A leaked per-user bearer token therefore stays valid until the operator restarts the daemon — exactly the scenario rotation needs to defeat.Surface
POST /api/users/{name}/rotate-keyis_owner_only_writeallowlist for/api/users/*non-GET (no middleware change needed; the prefix match inmiddleware.rs:109already blocks Admin/User/Viewer).password_hash::hash_password, persists via the existingpersist_userspath.{ "status": "ok", "new_api_key": "<plaintext, 64 hex chars>", "sessions_invalidated": <count> }RoleChangewith detail"api_key rotated by <actor> for user <name>"and the caller'suser_id— never the plaintext.Why owner-only
Rotating a user's key is a credential-management action with the same blast radius as user create/delete. Without an Owner gate, any Admin per-user API key could rotate the Owner's key and lock everyone else out — exactly the self-promotion attack the existing
is_owner_only_writeallowlist exists to block. Self-rotation through this endpoint is also denied for the same reason; users who want a fresh key ask an Owner.How session invalidation works (the load-bearing part)
SessionToken(the value stored inAppState.active_sessions) does not carry auser_id. Reading the auth flow atmiddleware.rs:603-668:/api/auth/dashboard-login, keyed by opaque token, tied to the single shareddashboard_user/dashboard_passcredential pair — not per-user.active_sessionsat all. Their bearer token is hashed against each entry inauth_state.user_api_keysvia Argon2 verify.So the actual revocation primitive is the
user_api_keyssnapshot, notactive_sessions. To make swapping that snapshot work this PR convertsAuthState.user_api_keysfromArc<Vec<ApiUserAuth>>toArc<RwLock<Vec<ApiUserAuth>>>and shares the sameArcwithAppState.user_api_keys. Afterpersist_usersfinishes the on-disk write + kernel reload, it now also rebuilds the in-memory snapshot — which means the next request that presents the OLD plaintext immediately fails Argon2 verify against the freshly-rotated entry. The same refresh fixes the latent bug forupdate_user/delete_user/import_usersalong the way (they were all silently waiting for a daemon restart before).I considered also clearing
active_sessionsin the rotate path, but rejected it: those sessions belong to the dashboard credential pair, not the rotated user, and clobbering them on every per-user rotation would force the operator to log back into the dashboard. If a future change ties dashboard sessions to aUserId, the rotate path can extendsessions_invalidatedto include those evictions; today the count reflects the per-user-bearer-token kill, which is the actual revocation that matters for this surface.Files
Backend
crates/librefang-api/src/routes/users.rs—rotate_user_keyhandler,RotateKeyResponse,generate_api_key_plaintext,rebuild_api_user_records, and thestate.user_api_keysrefresh wired intopersist_usersafter the kernel reload.crates/librefang-api/src/middleware.rs—AuthState.user_api_keysfield type change + read-once snapshot at the top ofauth()so each request only takes the lock once.crates/librefang-api/src/routes/mod.rs—AppState.user_api_keysfield.crates/librefang-api/src/server.rs— share the new Arc betweenAppStateandAuthState(mirrors the existingapi_key_lockpattern).crates/librefang-api/src/openapi.rs— register the new path and response schema.Dashboard
crates/librefang-api/dashboard/src/api.ts—rotateUserKey()+RotateUserKeyResponse.crates/librefang-api/dashboard/src/lib/http/client.ts— re-exports.crates/librefang-api/dashboard/src/lib/mutations/users.ts—useRotateUserKey()withuserKeys.lists()/userKeys.detail(name)invalidation inonSuccess.crates/librefang-api/dashboard/src/pages/UsersPage.tsx— "Rotate API key" button per row (only shown whenhas_api_key), confirm modal, and a copy-once display modal that makes the "this won't be shown again" wording explicit.Tests
crates/librefang-api/tests/users_test.rs(new tests):users_rotate_key_returns_new_plaintext— happy path, asserts 64-char hexusers_rotate_key_persists_new_hash— on-disk round-trip, hash differs from seedusers_rotate_key_invalidates_existing_session— the load-bearing test: pre-populates the live snapshot, rotates, asserts the OLD plaintext stops verifying and the NEW plaintext starts verifyingusers_rotate_key_unknown_user_404users_rotate_key_preserves_other_fields— RBAC M3 policy + memory_access + bindings + role survive rotation (regression cover for the M5/M3 preserve pattern)crates/librefang-api/tests/api_integration_test.rs:users_rotate_key_admin_returns_403— full-stack owner-only gate viastart_test_server_with_full_user_configs. Asserts both Admin-rotates-other and Bob-self-rotates get 403, mirroring the spec's pointer tousers_policy_put_owner_only.start_test_server_with_full_user_configsnow mountsroutes::users::router()so the gate can actually be exercised end-to-end.Test-harness alignment (mechanical)
Every existing
AppStateconstructor (5 test files +routes/agents.rs+librefang-testing) getsuser_api_keys: Arc::new(tokio::sync::RwLock::new(Vec::new())), and everyAuthStateconstructor inmiddleware.rstests is updated to wrap itsuser_api_keysin the sameRwLock. No semantic changes — the tests still assert the same things.Test plan
cargo build --workspace --lib)cargo test --workspace) — new tests above pass alongside the 2100+ existingcargo clippy --workspace --all-targets -- -D warnings)POST /api/users/{name}/rotate-key→ response carriesnew_api_keyFollow-ups not in scope
SessionTokento aUserIdso rotate can also evict dashboard sessions for the rotated user. Requires a schema change toSessionToken; not load-bearing for the per-user API key surface this PR fixes.subtlefor constant-time comparison — not used in this PR because Argon2 verify already gives constant-time semantics, and the plaintext returned is fresh per-rotation so there's no comparison risk on the response path.Review follow-ups (post-initial-review)
Atomicity hole in the persist path (race window)
Initial implementation took the
state.user_api_keys.write()lock AFTER the file write + kernel reload. Between thestd::fs::writeand the*state.user_api_keys.write().await = …swap, the kernel config already carried the new hash but the in-memoryVec<ApiUserAuth>the auth middleware actually verifies against still held the OLD record. Any request landing in that window — bounded byreload_configlatency, but exploitable under load — would still authenticate with the just-rotated plaintext.Fix (
crates/librefang-api/src/routes/users.rs::persist_users): acquirestate.user_api_keys.write()BEFORE the disk write, hold the guard across persist + reload + snapshot rebuild, drop only after the swap. Concurrent auth checks now either see the pre-rotation snapshot (fully consistent with the file at the moment they observed it) or block on this writer until the swap completes; never the in-between read where on-disk and live state disagree. Hold time is bounded byreload_config(a config reparse + a few innerRwLockwrites — ms scale), and authentication blocking with the rotated key is the correct behavior.Regression-guard test added in
crates/librefang-api/tests/users_test.rs::users_rotate_key_snapshot_consistent_with_disk_post_return: asserts the on-disk hash and the liveuser_api_keyssnapshot for the rotated user agree immediately after the handler returns 200. The deterministic content-level guard remainsusers_rotate_key_invalidates_existing_session.Audit detail: short fingerprint of the OLD api_key_hash
Initial detail string was
"api_key rotated by {actor} for user {name}"with no reference to the just-revoked credential. Forensically useless when correlating with an authentication-failure log lineauth failed with leaked key X.Fix (
crates/librefang-api/src/routes/users.rs::rotate_user_key): capture the OLDapi_key_hashfrom thepersist_usersclosure (now returnsOption<String>), computesha256(old_hash)truncated to the first 8 hex chars (32 bits — a hash-of-hash, no plaintext or reversibly-related material), and append(old: {fingerprint})to the audit detail. First-time key assignment via rotate (no prior hash) renders as(old: none)so downstream parsers see a stable shape.Helper:
api_key_hash_fingerprint(&str) -> String(uses the existing workspacesha2dep).Tests added:
users_rotate_key_audit_includes_old_hash_fingerprint— asserts the audit detail contains(old: <expected fingerprint>)and that none of{old plaintext, new plaintext, full old hash}leak into the audit detail.users_rotate_key_audit_old_none_when_no_prior_hash— first-time-key rotation produces(old: none).Title alignment with gate
Title now reads "owner-only" to match the actual
is_owner_only_writegate atmiddleware.rs(was previously "admin-only"). The Owner gate is the right policy for this surface — same blast radius as user create/delete; an Admin per-user key able to rotate the Owner's key would lock the Owner out of their own deployment.