Files
ironclaw/deny.toml
Illia Polosukhin 2a2fa3fcb4 feat(reborn): admin user-management API and UI (#5779)
* feat(reborn): admin user-management API and UI

Add an end-to-end admin user-management surface to the Reborn stack,
built on the existing StoredUser records in ironclaw_reborn_identity
(no new user store — see .claude/rules/discovery-claims.md for the
planning miss this corrects).

Layers:
- identity: new RebornUserDirectory trait (list/get/create/update/
  status/role/last-login/delete-cascade/count-active-admins) on the
  same FilesystemRebornIdentityStore, kept separate from the resolver
  so admin CRUD can't perturb mint/link invariants. delete_user
  cascades over external-identity + verified-email records. CONTRACT.md
  documents the three persisted record shapes.
- product_workflow: RebornServicesApi admin_* methods with caller
  authorization (admin/owner role or env-bearer operator) and
  last-admin protection; new AdminUserService port + DTOs.
- composition: admin_user_directory / admin_secrets / admin_token
  adapters; AdminApiTokenMinter port for minting the one-time API
  bearer on user-create. Mount now grants list+delete on /tenant-shared
  for the identity delete cascade.
- webui_v2: admin_users REST routes (GET/POST /admin/users,
  GET/PATCH/DELETE /admin/users/:id, status, role, GET/PUT/DELETE
  per-user secrets) + descriptors (body/rate limits).
- serve: wires a signed-session-store-backed minter (365-day API
  bearer that validates under the SSO login surface's own store).
- frontend: un-hide the admin nav + Users tab, wire admin-api.js to
  the real endpoints.

Tests: identity-store unit tests, product_workflow contract tests
(authz + last-admin + one-time token), webui_v2 descriptor contract,
and composition HTTP e2e (full lifecycle + API-token login + last-admin
over HTTP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): admin API e2e + JS client tests; fix admin request bodies

Follow-up test coverage for the admin user-management surface, plus a
frontend bug the JS test exposed.

- fix(webui-v2): `admin-api.js` passed request bodies as raw JS objects,
  but `apiFetch` forwards `options.body` to `fetch` unchanged (it does
  not serialize — callers must `JSON.stringify`, cf. `createThread`). In
  a real browser every admin write (create/update/status/role/secret)
  would have sent the string "[object Object]" and been rejected. Now
  stringified. Caught by the new JS test driving the real `apiFetch`.

- test(webui-v2): `admin-api.test.js` — 14 Node `--test` cases driving
  the real `apiFetch` (stubbing `globalThis.fetch`), asserting each
  method/path/body and the id/token normalization. `jsonBody()` guards
  the serialization fix above.

- test(e2e): repoint `test_admin_api.py` from the retired v1
  `/api/admin/*` monolith surface to the v2 `/api/webchat/v2/admin/*`
  routes on the real `ironclaw-reborn serve` binary (`reborn_v2_server`
  fixture, operator env-bearer). Adds the flagship
  `created_user_token_authenticates_as_that_user` round-trip, which
  proves serve.rs's minter wiring: the one-time api_token validates AS
  the new user at `/session` because the admin minter store and the SSO
  login store share `session_signing_secret`. Added to
  `reborn_coverage_tests.txt`.

Integration-tier note: admin coverage stays at the crate tier
(`ironclaw_reborn_composition/tests/admin_api_e2e.rs`); the
`AdminUserService` wiring is sealed `pub(crate)` in the composition
root and the `tests/integration` harness has no minter seam, so an
int-tier test would require faking the port ("wire the unwired") or
relocating the crate-tier test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): validate admin-minted API tokens without SSO

`ironclaw-reborn serve` unconditionally wires the admin-API token minter
(serve.rs) — "admin creates user" always returns a signed **session**
bearer as the one-time `api_token`. But the `SessionAuthenticator` that
validates session bearers was only wired on the SSO path: with no SSO
provider configured, `build_webui_auth_surface` returned just the
env-bearer authenticator. So in the default no-SSO deployment, an
admin-created user's API token failed with 401 on every request — the
feature was dead on arrival exactly where it is most likely used.

Compose the env-bearer (operator) authenticator with a
`SessionAuthenticator` over the same `signed_session_store` the minter
writes to, in the no-SSO branch of the auth surface. Operator
capabilities still follow the env token only (`CompositeAuthenticator::
mounts_operator_webui_config_routes`), so the minted session bearer stays
non-operator per the ingress crate's SSO-identity-only invariant.

`CompositeAuthenticator` (env-OR-session, previously used only on the SSO
path) is made `pub` and reused rather than duplicating the shim.

Regression: caught by the crate's binary e2e
`tests/e2e/scenarios/test_admin_api.py::test_created_user_token_authenticates_as_that_user`
(added in the prior commit), which now passes 10/10 against a freshly
built `ironclaw-reborn` — the crate-tier `admin_api_e2e.rs` masked this
because it hand-wired a `SessionAuthenticator` production serve.rs did not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): gate admin authz on status + serialize last-admin mutations

Two adversarial-testing findings on the admin user-management surface,
each fixed with its regression test.

1. Suspended admin retained full admin powers. `authorize_admin` checked
   `role.is_admin()` only, never `status`, so a SUSPENDED admin (role
   still reads Admin) kept complete control of the admin API. Now
   authorization requires admin/owner role AND `status == Active`, read
   on every call (never cached) — suspending an admin revokes their
   access immediately. Covered by
   `admin_suspended_admin_is_forbidden_on_every_verb`, plus a widened
   `admin_member_caller_is_forbidden_on_every_verb` /
   `admin_unknown_caller_is_forbidden_on_every_verb` sweep that drives
   the 403 through EVERY admin verb (not just list) per the
   "test through the caller" rule, including self-privilege-escalation.

2. Last-admin protection had a TOCTOU race. `ensure_not_last_admin`
   re-reads the active-admin count then mutates; two concurrent
   demotions each read "2 admins", both pass, and both land → 0 admins,
   stranding the tenant. Added a per-tenant admin-mutation lock (reusing
   the existing weak-ref keyed-lock registry, namespaced so keyspaces
   can't collide) held across the check+mutation in set_role / set_status
   / delete. Covered by
   `admin_last_admin_protection_survives_concurrent_demotion` (multi-
   thread runtime, concurrent demotions → exactly one lands, an admin
   always survives).

Also adds session-store characterization tests locking two intentional,
security-relevant bounds of the stateless signed-session denylist:
revocation does not survive a process restart, and denylist eviction can
resurrect a revoked-but-unexpired token under >4096-revocation pressure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): admin API adversarial e2e; fix malformed secret handle 500

Adds 7 adversarial HTTP tests to the crate-tier admin e2e (real router +
authenticator), and fixes a bug one of them surfaced.

Fix: a malformed secret handle (path-traversal-shaped, e.g. `..%2F..`)
was fail-closed (SecretHandle validation rejects it, nothing written) but
the rejection mapped to `AdminUserError::Internal` → **500**. The handle
is taken raw from the request path with no edge validation (a stale
comment claimed otherwise), so a client-supplied bad handle is the
client's fault: add `AdminUserError::InvalidInput` → **400**, and map the
`SecretHandle` construction failure to it in put/delete.

New e2e tests (crate `ironclaw_reborn_composition`, `admin_api_e2e.rs`):
- deleted admin's token → 403 on admin routes (delete revokes admin
  access via no-record); documents (does not lock) that the stateless
  token still authenticates non-admin `/session` — a separate
  session-revocation gap.
- suspended admin's token → 403 (exercises the status-gate fix).
- minted admin *session* bearer is denied operator routes (no
  `operator_webui_config`) while still allowed on admin user CRUD.
- forged / tampered / foreign-secret / expired tokens → 401.
- oversized create body → 413 (per-route 16 KiB cap, before the facade).
- secret-handle path traversal contained → now 400 (pins the fix above).
- malformed user_id → 404, invalid role/status enum → 422; never 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): address PR #5779 admin-API review (security, pagination, boundary types, UI)

Reviewer findings on the admin user-management surface:

Security
- serve: enforce the >=32-byte session-signing entropy floor
  unconditionally. The admin API token minter is always wired and signs
  user-visible session tokens, so a weak IRONCLAW_REBORN_WEBUI_TOKEN was
  an offline HMAC-forgery oracle even without SSO configured.
- identity: block suspended accounts at resolve_or_create so a suspended
  user cannot mint a fresh SSO session (previously status gated only the
  admin routes). New UserSuspended error maps to a fail-closed 403 in the
  SSO host adapter.
- identity: backfill the resolving tenant onto legacy tenantless user
  records on login, so pre-admin records stop being visible to every
  tenant's admin listing.
- composition: scope /tenant-shared delete authority to the
  reborn-identity subtree; the broad tenant-shared grant keeps
  read+write+list but no longer grants delete.

Boundary types
- webui_v2: parse {handle} into SecretHandle at the HTTP edge (400 on a
  malformed handle) and thread SecretHandle through the facade + port
  instead of a raw String re-validated downstream.

Pagination
- admin user listing takes a bounded limit + opaque cursor through the
  port, facade, identity directory, and JS client; the directory reads at
  most `limit` matching records per page instead of scanning and
  allocating the whole tenant. Default page size 100, max 200.

Frontend
- detect the forbidden state from ApiError.status/payload, not a message
  string match; remove the dead "Create Token" re-issue action (no
  re-issue endpoint exists).

Tests
- suspended-login block, tenant backfill, and pagination in the identity
  store; last-admin DELETE protection, status-filter forwarding, cursor
  paging, and malformed-cursor 400 at the facade; fetchAdminUser /
  deleteAdminUser / paginated fetchAdminUsers in the JS client.

Docs
- webui_v2 route table now lists the admin routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): close delete-vs-relink race with a delete tombstone; document last-admin lock scope

C4 — delete/SSO-relink race: the delete cascade removes a user's external
identities before its verified-email index, leaving a window where a concurrent
SSO login (via another provider on the same verified email) would re-link a
fresh identity record to the id being deleted, so future logins fast-path to a
ghost. delete_user now writes a per-user tombstone before the cascade and
removes it after; resolve_or_create refuses to re-link (or adopt) a tombstoned
user, failing closed with a retryable error until the delete settles. A crash
mid-cascade leaves the tombstone in place, which fails safe.

C5 — last-admin lock: documented the guarantee boundary. The per-tenant mutation
lock is airtight for the single-process `ironclaw-reborn serve` binary (the only
shipping deployment); a durable cross-replica lease is deferred until a
multi-replica mode exists, since a hand-rolled filesystem lease would add
crash-recovery/stale-takeover risk outweighing the bounded race it replaces.

Regression: resolve_or_create_refuses_to_relink_a_user_mid_delete, plus the
existing delete-cascade test confirms the tombstone is cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): unblock PR 5779 checks

* fix(ci): update composition pub-use snapshot

* fix(ci): stabilize trigger poller mutator test

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>
2026-07-08 13:55:54 +03:00

66 lines
2.1 KiB
TOML

[advisories]
unmaintained = "workspace"
yanked = "deny"
ignore = [
# Pre-existing advisories — tracked for upgrade in separate PRs
# tokio-tar PAX header parsing — sandbox containers only
"RUSTSEC-2025-0111",
# rustls-webpki advisories — 0.102.8 remains pinned by a libsql 0.9.30 transitive dep
# (via rustls 0.22 → hyper-rustls 0.25); keep ignored until that pin is gone.
# RUSTSEC-2026-0104: panic on empty `onlySomeReasons` BIT STRING during CRL parsing.
# We do not use CRLs, and the advisory explicitly notes apps that don't parse CRLs
# are unaffected. Same transitive pin as 0049/0098/0099 — tracked with them.
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
"RUSTSEC-2026-0104",
# rsa Marvin timing side-channel — test-only dependency used to mint
# throwaway local OIDC JWT fixtures; no production network-observable
# private-key operation is exposed by this dependency.
"RUSTSEC-2023-0071",
]
[licenses]
version = 2
private = { ignore = true }
allow = [
"MIT",
# MIT-0 (MIT No Attribution) is strictly more permissive than MIT —
# required by `jsonschema` (used for workspace document schema validation).
"MIT-0",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Unicode-DFS-2016",
"OpenSSL",
"Zlib",
"MPL-2.0",
"0BSD",
"BSL-1.0",
"CC0-1.0",
"Unlicense",
"CDLA-Permissive-2.0",
]
unused-allowed-license = "allow"
[bans]
multiple-versions = "warn"
wildcards = "deny"
# monty (Pydantic's embedded Python) is git-only (not on crates.io),
# so it inherently lacks a version constraint. Allow path-dep wildcards.
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = [
# Monty (Pydantic's embedded Python interpreter) — not yet on crates.io.
# Pulls in ruff_* crates from samuelcolvin/ruff at a pinned revision.
"https://github.com/pydantic/monty.git",
"https://github.com/samuelcolvin/ruff.git",
]