Skip to content

feat(security): owner-only API key rotation with live session kill#3233

Merged
houko merged 14 commits into
mainfrom
feat/rbac-api-key-rotation
Apr 26, 2026
Merged

feat(security): owner-only API key rotation with live session kill#3233
houko merged 14 commits into
mainfrom
feat/rbac-api-key-rotation

Conversation

@houko

@houko houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

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 today, because AuthState.user_api_keys is a frozen Arc<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-key

  • Owner-only — covered by the existing is_owner_only_write allowlist for /api/users/* non-GET (no middleware change needed; the prefix match in middleware.rs:109 already blocks Admin/User/Viewer).
  • No request body. Server generates a 32-byte random hex plaintext, hashes with Argon2id via the existing password_hash::hash_password, persists via the existing persist_users path.
  • Response shape:
    { "status": "ok", "new_api_key": "<plaintext, 64 hex chars>", "sessions_invalidated": <count> }
    The plaintext is the only time the server exposes it. Audit log records RoleChange with detail "api_key rotated by <actor> for user <name>" and the caller's user_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_write allowlist 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 in AppState.active_sessions) does not carry a user_id. Reading the auth flow at middleware.rs:603-668:

  1. The active-sessions store holds dashboard tokens issued by /api/auth/dashboard-login, keyed by opaque token, tied to the single shared dashboard_user/dashboard_pass credential pair — not per-user.
  2. Per-user API key callers never land in active_sessions at all. Their bearer token is hashed against each entry in auth_state.user_api_keys via Argon2 verify.

So the actual revocation primitive is the user_api_keys snapshot, not active_sessions. To make swapping that snapshot work this PR converts AuthState.user_api_keys from Arc<Vec<ApiUserAuth>> to Arc<RwLock<Vec<ApiUserAuth>>> and shares the same Arc with AppState.user_api_keys. After persist_users finishes 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 for update_user / delete_user / import_users along the way (they were all silently waiting for a daemon restart before).

I considered also clearing active_sessions in 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 a UserId, the rotate path can extend sessions_invalidated to 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.rsrotate_user_key handler, RotateKeyResponse, generate_api_key_plaintext, rebuild_api_user_records, and the state.user_api_keys refresh wired into persist_users after the kernel reload.
  • crates/librefang-api/src/middleware.rsAuthState.user_api_keys field type change + read-once snapshot at the top of auth() so each request only takes the lock once.
  • crates/librefang-api/src/routes/mod.rsAppState.user_api_keys field.
  • crates/librefang-api/src/server.rs — share the new Arc between AppState and AuthState (mirrors the existing api_key_lock pattern).
  • crates/librefang-api/src/openapi.rs — register the new path and response schema.

Dashboard

  • crates/librefang-api/dashboard/src/api.tsrotateUserKey() + RotateUserKeyResponse.
  • crates/librefang-api/dashboard/src/lib/http/client.ts — re-exports.
  • crates/librefang-api/dashboard/src/lib/mutations/users.tsuseRotateUserKey() with userKeys.lists() / userKeys.detail(name) invalidation in onSuccess.
  • crates/librefang-api/dashboard/src/pages/UsersPage.tsx — "Rotate API key" button per row (only shown when has_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 hex
    • users_rotate_key_persists_new_hash — on-disk round-trip, hash differs from seed
    • users_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 verifying
    • users_rotate_key_unknown_user_404
    • users_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 via start_test_server_with_full_user_configs. Asserts both Admin-rotates-other and Bob-self-rotates get 403, mirroring the spec's pointer to users_policy_put_owner_only.
    • start_test_server_with_full_user_configs now mounts routes::users::router() so the gate can actually be exercised end-to-end.

Test-harness alignment (mechanical)

Every existing AppState constructor (5 test files + routes/agents.rs + librefang-testing) gets user_api_keys: Arc::new(tokio::sync::RwLock::new(Vec::new())), and every AuthState constructor in middleware.rs tests is updated to wrap its user_api_keys in the same RwLock. No semantic changes — the tests still assert the same things.

Test plan

  • CI builds (cargo build --workspace --lib)
  • CI tests (cargo test --workspace) — new tests above pass alongside the 2100+ existing
  • CI clippy (cargo clippy --workspace --all-targets -- -D warnings)
  • CI dashboard typecheck + build
  • Manual smoke (post-merge):
    • Seed a user with an API key, hit a protected endpoint with the old plaintext → 200
    • POST /api/users/{name}/rotate-key → response carries new_api_key
    • Hit the same protected endpoint with the OLD plaintext → 401 (the actual session kill)
    • Hit it with the NEW plaintext → 200
    • Restart the daemon, repeat with the NEW plaintext → still 200 (persistence)

Follow-ups not in scope

  • Self-service rotation for non-Owner users — would need a separate scoped endpoint with a fresh-credential-required gate; intentionally out of scope here.
  • Tying dashboard SessionToken to a UserId so rotate can also evict dashboard sessions for the rotated user. Requires a schema change to SessionToken; not load-bearing for the per-user API key surface this PR fixes.
  • The spec mentioned subtle for 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 the std::fs::write and the *state.user_api_keys.write().await = … swap, the kernel config already carried the new hash but the in-memory Vec<ApiUserAuth> the auth middleware actually verifies against still held the OLD record. Any request landing in that window — bounded by reload_config latency, but exploitable under load — would still authenticate with the just-rotated plaintext.

Fix (crates/librefang-api/src/routes/users.rs::persist_users): acquire state.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 by reload_config (a config reparse + a few inner RwLock writes — 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 live user_api_keys snapshot for the rotated user agree immediately after the handler returns 200. The deterministic content-level guard remains users_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 line auth failed with leaked key X.

Fix (crates/librefang-api/src/routes/users.rs::rotate_user_key): capture the OLD api_key_hash from the persist_users closure (now returns Option<String>), compute sha256(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 workspace sha2 dep).

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_write gate at middleware.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.

…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
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review size/L 250-999 lines changed labels Apr 26, 2026
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
…ation

# Conflicts:
#	crates/librefang-api/dashboard/src/api.ts
#	crates/librefang-api/tests/api_integration_test.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
…ation

# Conflicts:
#	crates/librefang-api/tests/api_integration_test.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
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.
@github-actions github-actions Bot added area/sdk JavaScript and Python SDKs has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
…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
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

@github-actions github-actions Bot removed the size/L 250-999 lines changed label Apr 26, 2026
@github-actions github-actions Bot added size/XL 1000+ lines changed has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
houko added 2 commits April 26, 2026 23:08
…ation

# Conflicts:
#	crates/librefang-api/dashboard/src/pages/UsersPage.tsx
#	crates/librefang-api/tests/users_test.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
@houko houko changed the title feat(security): RBAC follow-up — admin-only API key rotation with live session kill feat(security): owner-only API key rotation with live session kill Apr 26, 2026
@houko
houko merged commit 96a6226 into main Apr 26, 2026
24 checks passed
@houko
houko deleted the feat/rbac-api-key-rotation branch April 26, 2026 14:30
@github-actions github-actions Bot removed the ready-for-review PR is ready for maintainer review label Apr 26, 2026
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.
@houko houko mentioned this pull request Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sdk JavaScript and Python SDKs size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant