Skip to content

feat(reborn): admin user-management API and UI - #5779

Merged
serrrfirat merged 12 commits into
mainfrom
feat/reborn-admin-user-api
Jul 8, 2026
Merged

serrrfirat merged 12 commits into
mainfrom
feat/reborn-admin-user-api

Conversation

@ilblackdragon

@ilblackdragon ilblackdragon commented Jul 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds an end-to-end admin user-management surface to the Reborn stack, threaded through all five layers (identity → product_workflow → composition → webui_v2 → serve → frontend).

The surface is built on the existing StoredUser records already persisted by ironclaw_reborn_identity on every SSO login — it does not stand up a new user store. (An early plan miss assumed Reborn had no user store; the correction is captured as a new agent rule, .claude/rules/discovery-claims.md.)

What ships

Identity (ironclaw_reborn_identity) — new RebornUserDirectory trait on the same FilesystemRebornIdentityStore, kept deliberately separate from RebornIdentityResolver so admin CRUD can't perturb the mint/link/create invariants:

  • list_users / get_user / create_user (admin-mint, no external identity) / update_profile / update_status / update_role / record_last_login / count_active_admins
  • delete_user cascades — removes external-identity records + verified-email index (the one sanctioned unwind of the linking invariants)
  • CONTRACT.md now documents the three persisted record shapes and the directory surface.

Product workflow (ironclaw_product_workflow) — RebornServicesApi admin_* methods with caller authorization (admin/owner role or env-bearer operator) and last-admin protection enforced in the facade; new AdminUserService port + request/response DTOs.

Composition (ironclaw_reborn_composition) — admin_user_directory / admin_secrets / admin_token adapters; AdminApiTokenMinter port for the one-time API bearer minted on user-create. The /tenant-shared mount now grants list+delete for the identity delete cascade.

WebUI v2 (ironclaw_webui_v2) — REST routes + descriptors (body/rate limits):

  • GET|POST /admin/users
  • GET|PATCH|DELETE /admin/users/:id
  • POST status, POST role
  • GET|PUT|DELETE per-user secrets

Serve (ironclaw-reborn serve) — wires a signed-session-store-backed minter that issues a 365-day API bearer validating under the SSO login surface's own store.

Frontend — un-hides the admin nav + Users tab and wires admin-api.js to the real endpoints (dashboard/usage analytics tabs remain out of scope).

Authorization & safety

  • Routes mount unconditionally; a non-admin caller gets 403 from the facade, not a hidden surface.
  • Last-admin protection blocks demoting/suspending/deleting the final active admin.
  • Admin-created users are token/API users with no verified-email index, so they don't weaken the OAuth-surface linking gate.
  • Secrets written admin-side share the user's master key, so they decrypt under the user's own store.

Tests

  • identity — unit tests for the directory surface (create/list/get/update/status/role/last-login, delete cascade, malformed-id read-back).
  • product_workflow — contract tests: member caller forbidden, admin lists/creates with one-time token, operator bypass, last-admin protection (blocks demote/suspend, allows with a second admin).
  • webui_v2 — descriptor contract for the new routes.
  • composition — HTTP e2e through the composed router: full admin lifecycle + API-token login, last-admin protection over HTTP.

Verification

  • cargo fmt --check clean
  • cargo clippy --all --tests --all-features clean (0 warnings)
  • cargo test for ironclaw_reborn_identity, ironclaw_product_workflow, ironclaw_webui_v2 (--all-features) — all pass
  • cargo test -p ironclaw_reborn_composition --features webui-v2-beta,libsql — admin tests pass; 16 compute-heavy runtime:: tests hit their 10s timeout only under concurrent-suite load and pass in isolation (unrelated to this change)

🤖 Generated with Claude Code


Follow-up test coverage (2nd commit)

Added at reviewer request, and it surfaced a real frontend bug:

  • Bug fix — admin-api.js request bodies. The client passed bodies as raw JS objects, but apiFetch forwards options.body to fetch unchanged (callers must JSON.stringify, per createThread). In a real browser every admin write (create/update/status/role/secret) would have sent "[object Object]" and been rejected. Now stringified. Neither the crate-tier nor the httpx e2e catches this (both send real JSON) — only a JS test through the real apiFetch does.
  • JS unit tests — 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; a jsonBody() helper guards the serialization fix.
  • E2E repoint — test_admin_api.py. Moved from the retired v1 /api/admin/* monolith surface to the v2 /api/webchat/v2/admin/* routes on the real ironclaw-reborn serve binary. Adds the flagship created_user_token_authenticates_as_that_user round-trip: the one-time api_token validates AS the new user at /session, proving serve.rs's minter wiring (admin minter store and SSO login store share session_signing_secret). Added to reborn_coverage_tests.txt.

Test-tier decision (integration vs. crate)

Admin coverage stays at the crate tier (ironclaw_reborn_composition/tests/admin_api_e2e.rs), not tests/integration/. The AdminUserService is wired in exactly one place — build_webui_services(&RebornRuntime) — and its adapter (RebornAdminUserDirectory), the secret provisioner, and the three runtime feeder accessors are all pub(crate) in the composition crate, sealed to the composition root. The tests/integration/webui_v2_product_api.rs harness never builds a RebornRuntime (it hand-builds RebornServices::new) and its RebornBuildInput path has no with_admin_api_token_minter seam (that setter is on RebornRuntimeInput). Reaching the real path at the int tier would require either faking the AdminUserService port — "wire the unwired," forbidden by .claude/rules/testing.md — or relocating the exact construction admin_api_e2e.rs already is; and the int-tier mount helper bypasses the bearer authenticator, so it can't exercise the operator/member authorization or the minted-token-login chain this surface is built around.

Bug caught + fixed by running the binary e2e (3rd commit)

Running test_admin_api.py against a freshly built ironclaw-reborn failed the flagship token round-trip — a real production bug: serve.rs always wires the admin-API token minter, but the SessionAuthenticator that validates the minted session bearer was only wired on the SSO path. In the default no-SSO ironclaw-reborn serve deployment, an admin-created user's api_token got 401 on every request — the feature was dead on arrival exactly where it's most likely used. The crate-tier admin_api_e2e.rs masked it (it hand-wired a SessionAuthenticator production serve did not).

Fix: build_webui_auth_surface's no-SSO branch now composes the env-bearer (operator) authenticator with a SessionAuthenticator over the same signed_session_store the minter writes to. Operator capabilities still follow the env token only, so the minted session bearer stays non-operator (per the ingress crate's SSO-identity-only invariant). CompositeAuthenticator (env-OR-session) is made pub and reused rather than duplicating the shim.

Verification (follow-up)

  • node --test .../admin-api.test.js — 14/14 pass
  • Binary e2e run locally against a fresh ironclaw-reborn (webui-v2-beta): test_admin_api.py — 10/10 pass after the no-SSO fix (was 9/10, the flagship round-trip red before the fix)
  • cargo test -p ironclaw_reborn_webui_ingress --all-features — pass; cargo clippy -p ironclaw_reborn_webui_ingress -p ironclaw_reborn_cli --features webui-v2-beta --tests — clean

Stress / adversarial testing round (commits 4–5)

Adversarial and concurrency tests across authz, tokens, last-admin protection, session revocation, and input validation. Three more real bugs found and fixed, each with its regression test.

Bugs fixed:

  1. Suspended admin kept full admin powers. authorize_admin checked role only, never status — a suspended admin (role still Admin) retained complete control. Now requires role AND status == Active (read every call, never cached).
  2. Last-admin protection TOCTOU race. Two concurrent demotions each read "2 admins", both passed, both landed → 0 admins. Added a per-tenant admin-mutation lock serializing role/status/delete across the check+mutation.
  3. Malformed secret handle → 500. A traversal-shaped handle was fail-closed (nothing written) but returned 500; the "validated at the edge" comment was false. Added AdminUserError::InvalidInput → 400.

Tests added:

  • Facade (contract tier): per-verb 403 sweep for member/no-record/suspended callers (+ self-escalation); multi-thread concurrent-demotion race guard.
  • Token/HTTP (crate e2e): deleted/suspended token → 403 on admin routes; admin session bearer denied operator routes; forged/tampered/foreign/expired tokens → 401; oversized body → 413; secret-handle traversal → 400; malformed user_id → 404 / bad enums → 422 / never 500.
  • Session store: revocation doesn't survive restart; denylist eviction can resurrect a revoked-but-unexpired token under >4096-revocation pressure.

Documented gaps (not fixed — larger scope):

  • Session-revocation gap: a deleted/suspended user's stateless api_token still authenticates non-admin routes until its 365-day expiry. Delete revokes admin access (no record) but not the session itself; the signed-session denylist is process-local and per-token, with no per-user revocation. Flagged for a follow-up (durable/per-user session revocation).
  • Delete-during-parked-turn (feat: Add Okta SSO WASM tool for profile management and app catalog #13): deferred — needs a real running turn; the admin e2e harness uses a no-op gateway and the integration harness doesn't wire the admin service.

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>
Copilot AI review requested due to automatic review settings July 7, 2026 17:11
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines labels Jul 7, 2026
@ironloopai

ironloopai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

⏳ IronLoop Review Status

Head: 17b03065aa2b0678ca84549fce69fb0b975553aa
Result: No reviewer jobs are scheduled yet.
Next: Run @ironloopai review to start reviewers.
Updated: 2026-07-08T10:31:09.874Z

Current reviewers:

Reviewer State Verdict Findings Last update
none Queued N/A No reviewer jobs scheduled yet. N/A
Reviewer summaries
Reviewer Detail
none No reviewer jobs scheduled yet.
Recent activity
Time Reviewer State Detail
N/A N/A Waiting No progress events recorded yet.
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent-id-or-alias>
  • @ironloopai status
Run metadata

Admission: webhook accepted the request and IronLoop persisted review state before this projection.

@github-actions github-actions Bot added the risk: low Changes to docs, tests, or low-risk modules label Jul 7, 2026
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 7, 2026 17:12 Destroyed
@github-actions github-actions Bot added the contributor: core 20+ merged PRs label Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8f1d8953-edfb-47a2-8213-8fbe6295251b

📥 Commits

Reviewing files that changed from the base of the PR and between ee52baa and 17b0306.

📒 Files selected for processing (1)
  • crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added WebChat v2 admin user management in the Web UI (list/create/view/update/delete), including last-admin protection, role/status changes, and per-user secret CRUD.
    • Added one-time admin API token delivery for newly created users, and surfaced the Admin page (defaulting to the Users tab).
  • Bug Fixes
    • Strengthened server-side authorization and input validation (denying revoked/suspended/expired/invalid access), with clearer “forbidden” handling.
    • Hardened last-active-admin behavior under concurrency and improved pagination/cursor handling for admin listings.

Walkthrough

Adds a WebChat v2 admin user-management surface end-to-end: filesystem-backed user records and delete cascades, a fail-closed admin service and composition wiring, CLI/session-token minting, HTTP routes/handlers, frontend client/UI updates, and real-runtime contract/E2E coverage.

Changes

Admin user management

Layer / File(s) Summary
Identity directory and storage
crates/ironclaw_reborn_identity/src/user_directory.rs, .../lib.rs, .../filesystem_store.rs, .../filesystem_store/{directory,paths,record,tests}.rs, CONTRACT.md
RebornUserDirectory and the filesystem-backed implementation now carry admin status/role, tenant scoping, tombstone delete cascades, last-login tracking, and admin-counting behavior; persisted record shapes and contract text were expanded to match.
AdminUserService port and RebornServices facade
crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs, .../reborn_services.rs, .../lib.rs, tests/reborn_services_contract.rs
Adds admin DTOs, the AdminUserService port, default-deny fallback, RebornServices admin endpoints, per-tenant mutation locking, sanitized authorization/error mapping, and contract coverage for last-admin and concurrency cases.
Composition adapters and runtime wiring
crates/ironclaw_reborn_composition/src/{admin_secrets,admin_token,admin_user_directory,factory,lib,runtime,runtime_input,webui}.rs, test_support/local_dev_boot.rs, Cargo.toml
Adds admin secret provisioning, admin API token minting, the user-directory adapter, and runtime/factory wiring to expose the admin surface when the required components are present.
CLI token minting and session wiring
crates/ironclaw_reborn_cli/src/commands/{serve,webui_auth,user_directory}.rs, tests/smoke.rs, crates/ironclaw_reborn_webui_ingress/src/{lib,signed_session_login}.rs
Adds the signed-session admin token minter, unconditional session-signing entropy checks, a shared session-store helper, and resolver error mapping plus smoke coverage.
WebChat v2 HTTP routes and handlers
crates/ironclaw_webui_v2/src/{descriptors,handlers,lib,router}.rs, CLAUDE.md, tests/webui_v2_descriptors_contract.rs
Adds admin route descriptors, ingress policies, HTTP handlers, router mounting, public re-exports, docs, and locked-policy tests for user and secret endpoints.
Admin frontend UI and client
crates/ironclaw_webui_v2/static/js/app/routes.js, pages/admin/*
Makes the /admin route visible, defaults the page to users, replaces TODO admin API stubs with v2 calls, and removes token-creation UI.
Admin API end-to-end tests
crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs, tests/e2e/scenarios/test_admin_api.py, tests/e2e/reborn_coverage_tests.txt
Adds real-runtime HTTP coverage for user lifecycle, one-time token login, secret handling, authorization boundaries, and last-admin protection.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • nearai/ironclaw#5057 — extends the same RebornServices / RebornServicesApi facade and route-handler wiring pattern on the WebUI surface.
  • nearai/ironclaw#5185 — shares the operator_webui_config bypass path used here to authorize admin user-management access.

Suggested reviewers: think-in-universe

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it omits the required Linked Issue and several template sections/checklists. Add an approved issue link and fill the template sections for Change Type, Validation, Security Impact, Database Impact, Rollback Plan, Review Follow-Through, and Review track.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits and accurately summarizes the admin user-management API/UI change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ IronLoop Review: reviewer

Verdict: ❌ Changes requested
Findings: 1 blocking / 0 notes
Next: Fix the blocking findings, push the PR branch, then re-run this reviewer.
Head: f4fda265d016f01d7aae0bee68817f1f4eeba50b

Run details

Status: Current
Needs human: no
Needs validation: no

**Inline candidates:** 1

Summary

Found a blocking authorization regression in the new admin user-management surface.

Findings

1. ❌ [HIGH] Suspended admins still pass admin authorization

Location: crates/ironclaw_product_workflow/src/reborn_services.rs:2889
authorize_admin grants access to any persisted admin/owner role without checking AdminUserStatus::Active. The new status endpoint and last-admin logic treat suspension as removing an active admin, but a suspended admin's existing bearer can still call every admin API, including creating users, changing roles, and provisioning secrets. Require user.status == AdminUserStatus::Active in this authorization check, and add a caller-level test that suspending an admin immediately causes admin routes to return 403.

Developer follow-up

After fixing this feedback:

  1. Push the fix to this PR branch.
  2. Re-run this reviewer with @ironloopai review --agent reviewer if you only changed this reviewer's findings.
  3. Re-run all reviewers with @ironloopai review when the fix may affect multiple areas.
  4. Use @ironloopai status to check queued/running/completed/stale/stalled state while reviewers run.

.await
.map_err(map_admin_user_error)?;
match record {
Some(user) if user.role.is_admin() => Ok(()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also require user.status == AdminUserStatus::Active. As written, suspending an admin only changes the count used by last-admin protection; the suspended admin's existing bearer still clears this role-only check and can continue using all admin routes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive admin user-management surface, enabling user CRUD, status/role updates, and per-user secret provisioning. It defines the AdminUserService port, implements it via a composition adapter over the identity directory and secret provisioner, exposes the corresponding HTTP endpoints in the WebUI v2 router, and wires up the admin Users tab in the frontend. Feedback on these changes highlights a performance concern in list_users due to O(N) directory scans, a potential 500 Internal Server Error caused by a lack of secret handle validation at the HTTP boundary, and a bug in the frontend API client where simultaneous role and profile updates are partially ignored.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +247 to +251
async fn list_users(
&self,
tenant_id: &TenantId,
status: Option<RebornUserStatus>,
) -> Result<Vec<RebornUser>, RebornIdentityError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance & Scalability Concern: O(N) Filesystem Reads on Listing

Since user records are not tenant-partitioned in the filesystem path, list_users must list the entire global users/ directory and perform a separate read_record (filesystem read and JSON deserialization) for every single user in the system just to filter by tenant_id and status.

As the total number of users across all tenants grows, this will become a severe performance bottleneck and will not scale.

Recommendation:
Optimize filesystem-backed listing operations by using index-based queries to avoid O(N) directory scans. Maintain an index file that directly lists the paths to the relevant items to avoid costly directory traversal and scanning. Implement a fallback to directory scanning only when the backend explicitly reports the index operation as unsupported.

References
  1. To optimize list operations that involve scanning multiple directories and files, maintain an index file that directly lists the paths to the relevant items. This avoids costly directory traversal and scanning.
  2. Optimize filesystem-backed listing operations by using index-based queries to avoid O(N) directory scans. Implement a fallback to directory scanning only when the backend explicitly reports the index operation as unsupported.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b5e46e5 with bounded pagination (limit+cursor, default 100 / max 200) through the port, facade, directory, and JS client; the directory reads at most limit matching records per page instead of an O(N) scan-and-allocate. A tenant/status-partitioned index (to also bound the directory listing itself) is noted as a follow-up.

Comment on lines +217 to +219
// The handle is validated at the HTTP edge (webui_v2 descriptor/handler);
// a construction failure here is an internal inconsistency.
let handle = SecretHandle::new(&handle).map_err(|_| AdminUserError::Internal)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Incorrect Assumption & Potential 500 Error

The comment states that the handle is validated at the HTTP edge, but admin_put_user_secret and admin_delete_user_secret in ironclaw_webui_v2/src/handlers.rs accept handle as a raw String without any validation.

As a result, if a user passes an invalid handle in the URL path, SecretHandle::new will fail here and return AdminUserError::Internal, which the facade maps to a 500 Internal Server Error instead of a 400 Bad Request.

Recommendation:
Validate security-sensitive inputs and domain-specific types at the boundary level (e.g., the HTTP edge in ironclaw_webui_v2/src/handlers.rs) using a validating constructor like SecretHandle::new and return a proper 400 Bad Request validation error if it fails. This keeps constructors downstream infallible and centralizes validation logic at the boundary.

References
  1. Validate security-sensitive inputs like service base URLs at the factory or boundary level rather than in individual constructors to keep constructors infallible and centralize security logic.
  2. When constructing a domain-specific type from an external, untrusted source, use a validating constructor instead of a trusted constructor to ensure data is canonicalized and validated at the boundary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b5e46e5. Handle validation moved to the HTTP boundary (SecretHandle::new in the webui_v2 handler → 400). The composition adapter already mapped a bad handle to InvalidInput/400 as of 76ea3c2; this change makes the boundary the single validation point per the typed-internals rule.

Comment on lines +46 to +47
export async function updateAdminUser(id, payload) {
if (payload && Object.prototype.hasOwnProperty.call(payload, "role")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Potential Bug: Partial Update Ignored

If payload contains both role and other profile fields (like display_name or metadata), the if block will match and return after updating only the role, silently ignoring the other updates.

While the UI may only send one or the other today, this is a fragile assumption that can easily lead to silent update failures if the UI is modified in the future.

Recommendation:
If both are present, either perform both requests (e.g., using Promise.all) or explicitly handle/reject the combined payload to prevent silent data loss.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The admin UI only ever sends { role } OR { display_name / metadata } today — never both — and the router-by-payload keeps that contract explicit. The backend also splits these into distinct endpoints (POST /role vs PATCH /). We're leaving the single-key routing as-is rather than adding speculative combined-update handling; if the UI ever sends both, the right fix is at that call site. Not changing in this pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an end-to-end admin user-management surface to the Reborn stack, wiring new admin CRUD + per-user secret provisioning + one-time API token minting through identity → product_workflow → composition → webui_v2 → serve → frontend.

Changes:

  • Introduces an identity-level RebornUserDirectory over persisted StoredUser records, including delete cascade and active-admin counting.
  • Adds a product-workflow AdminUserService port + facade methods with role/operator authorization and last-admin protection.
  • Wires new WebUI v2 admin routes and frontend admin users tab, plus serve-layer token minting via a signed session store.

Reviewed changes

Copilot reviewed 36 out of 37 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs Extends route descriptor contract to cover new admin endpoints.
crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js Replaces stub admin client with real v2 /admin/users* calls + secrets APIs.
crates/ironclaw_webui_v2/static/js/pages/admin/hooks/useAdminUsers.js Hooks updated to integrate one-time token behavior and admin CRUD mutations.
crates/ironclaw_webui_v2/static/js/pages/admin/admin-page.js Makes Users the default admin tab and fallback route.
crates/ironclaw_webui_v2/static/js/app/routes.js Un-hides admin nav and routes only the Users tab.
crates/ironclaw_webui_v2/src/router.rs Mounts the new admin routes (users + role/status + secrets).
crates/ironclaw_webui_v2/src/lib.rs Re-exports new admin route IDs for consumers/tests.
crates/ironclaw_webui_v2/src/handlers.rs Adds admin HTTP handlers delegating to RebornServicesApi.
crates/ironclaw_webui_v2/src/descriptors.rs Defines admin route IDs/patterns and descriptors (limits, auth, audit).
crates/ironclaw_reborn_webui_ingress/src/signed_session_login.rs Exposes a deterministic signed session-store constructor for token mint/verify.
crates/ironclaw_reborn_webui_ingress/src/lib.rs Re-exports signed_session_store for serve/tests.
crates/ironclaw_reborn_identity/src/user_directory.rs Defines the admin-facing directory trait and domain types.
crates/ironclaw_reborn_identity/src/lib.rs Exports directory types and adds UserNotFound error.
crates/ironclaw_reborn_identity/src/filesystem_store/tests.rs Adds unit tests covering directory CRUD, delete cascade, and legacy record defaults.
crates/ironclaw_reborn_identity/src/filesystem_store/record.rs Extends persisted StoredUser shape with status/role/tenant/metadata/back-compat defaults.
crates/ironclaw_reborn_identity/src/filesystem_store/paths.rs Adds helpers for enumerating users directory and walking external identity trees.
crates/ironclaw_reborn_identity/src/filesystem_store/directory.rs Implements RebornUserDirectory over the filesystem store, including CAS updates and cascade delete.
crates/ironclaw_reborn_identity/src/filesystem_store.rs Writes new user-record fields on SSO login.
crates/ironclaw_reborn_identity/CONTRACT.md Documents persisted shapes and the new user-directory surface/invariants.
crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs Adds HTTP e2e tests for the composed admin surface + token login.
crates/ironclaw_reborn_composition/src/webui.rs Wires AdminUserService into webui services when all dependencies are present.
crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs Updates test helper for new secret-store factory return shape.
crates/ironclaw_reborn_composition/src/runtime.rs Adds runtime accessors for user directory, admin secret provisioner, and token minter.
crates/ironclaw_reborn_composition/src/runtime_input.rs Adds optional admin token-minter input to runtime construction.
crates/ironclaw_reborn_composition/src/lib.rs Expands mount permissions and exposes the admin token-minter port.
crates/ironclaw_reborn_composition/src/factory.rs Returns secret-store crypto for admin provisioning and wires the admin secret provisioner.
crates/ironclaw_reborn_composition/src/admin_user_directory.rs Composition adapter implementing product-workflow AdminUserService over identity + secrets + token minting.
crates/ironclaw_reborn_composition/src/admin_token.rs Defines the AdminApiTokenMinter port used by composition/serve.
crates/ironclaw_reborn_composition/src/admin_secrets.rs Implements admin per-user secret provisioning by building target-scoped secret stores.
crates/ironclaw_reborn_composition/Cargo.toml Adds dev-deps needed for admin e2e (ingress + chrono + secrecy).
crates/ironclaw_reborn_cli/src/commands/serve.rs Wires a session-store-backed admin API token minter into serve.
crates/ironclaw_product_workflow/tests/reborn_services_contract.rs Adds contract tests for admin authorization, operator bypass, and last-admin protection.
crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs Introduces the admin port + HTTP wire DTOs + fail-closed default service.
crates/ironclaw_product_workflow/src/reborn_services.rs Wires the port into RebornServices and implements admin facade methods.
crates/ironclaw_product_workflow/src/lib.rs Re-exports admin types from reborn_services.
Cargo.lock Adds ironclaw_reborn_webui_ingress to composition dev dependency graph.
.claude/rules/discovery-claims.md Adds a new agent rule documenting evidence standards for load-bearing discovery claims.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +800 to +804
// list + delete are needed by the Reborn identity store's admin
// user-directory: enumeration (`list_users`) and the delete cascade
// (removing a user's identity/verified-email records) live under
// `/tenant-shared/reborn-identity/…`.
MountPermissions::read_write_list_delete(),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5e46e5. /tenant-shared now grants only read+write+list (no delete); delete authority is scoped to a new /tenant-shared/reborn-identity sub-mount. Longest-prefix mount matching routes identity paths (which need the delete cascade) to the full grant and everything else to the delete-less one, so a compromised tenant-shared writer can't delete across unrelated subtrees.

Comment on lines +190 to +194
// Every handler delegates straight to the facade, which enforces admin
// authorization (operator token or admin/owner role) and last-admin protection.
// The `{user_id}` path segment is parsed into a `UserId` here so a malformed id
// is a 400 before the facade runs; the `{handle}` segment stays a String and is
// validated deeper (the secret store rejects a bad handle).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5e46e5. The {handle} segment is now parsed into SecretHandle at the HTTP edge (parse_admin_secret_handle → 400 on a malformed handle) and threaded as the typed value through the facade + port; the composition adapter no longer re-validates a raw string, so a bad handle is a sanitized 400, never a downstream 500.

Comment on lines +323 to +329
let user_id = parse_admin_user_id(user_id)?;
Ok(Json(
state
.services()
.put_admin_user_secret(caller, user_id, handle, body)
.await?,
))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5e46e5. The {handle} segment is now parsed into SecretHandle at the HTTP edge (parse_admin_secret_handle → 400 on a malformed handle) and threaded as the typed value through the facade + port; the composition adapter no longer re-validates a raw string, so a bad handle is a sanitized 400, never a downstream 500.

Comment on lines +338 to +344
let user_id = parse_admin_user_id(user_id)?;
Ok(Json(
state
.services()
.delete_admin_user_secret(caller, user_id, handle)
.await?,
))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5e46e5. The {handle} segment is now parsed into SecretHandle at the HTTP edge (parse_admin_secret_handle → 400 on a malformed handle) and threaded as the typed value through the facade + port; the composition adapter no longer re-validates a raw string, so a bad handle is a sanitized 400, never a downstream 500.

Comment on lines 59 to 61
suspendUser: suspendMut.mutateAsync,
activateUser: activateMut.mutateAsync,
createToken: (userId, name) => tokenMut.mutateAsync({ userId, name }),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5e46e5. The re-issue token controls were removed from the UI (no re-issue endpoint exists), so no caller awaits the rejecting createUserToken anymore. The dead createToken/newToken wiring was dropped from the hook.

@henrypark133

Copy link
Copy Markdown
Collaborator

Nice work on the authorization design here — authorize_admin re-reading the role on every call (no caching), tenant always derived from the caller rather than the body, and the last-admin TOCTOU re-read are all the right defaults for a multi-tenant admin surface. And the crate-tier admin_api_e2e.rs test is genuinely strong on the authorization axis: member-403, admin-200, token-mint→login round trip, last-admin 409, cascade-delete→404, secrets never echoed — through a real bearer-auth path, not a bypass.

One process note per .claude/rules/testing.md "Integration-First Coverage (Reborn)": production-wired Reborn behavior is supposed to land in tests/integration/, driven through the harness, with crate-tier only as a stated fallback when the int tier can't reach the path — this PR doesn't cite that reason in the Tests section, and I think it mostly can reach this. tests/integration/support/webui_mount.rs (mount_webui_v2_router + webui_caller_for, exercised in webui_v2_product_api.rs, #5655) already mounts the real webui_v2_router over a real RebornServicesApi with an injected WebUiAuthenticatedCaller — a non-admin-caller-denied scenario and an admin-CRUD-round-trip scenario for /admin/users* would drop into that suite cheaply.

The gap I'd actually flag as worth closing: tenant isolation of the admin surface is only proven at the raw FilesystemRebornIdentityStore::list_users level (create_then_list_and_get_roundtrip) — nothing drives it through the caller→facade→router path with two different-tenant WebUiAuthenticatedCallers (the group_multiuser two-actor pattern from #5720 builds exactly that shape) to confirm an admin in tenant A can't see/mutate tenant B's users via the real route. Tenant being caller-derived rather than body-supplied makes this low-risk in practice, but it's the one authorization-adjacent path with zero coverage above the storage layer, which is worth pinning at the seam that actually matters rather than trusting the design intent. Happy to help wire it if useful — both harness pieces are already sitting there.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_product_workflow/src/reborn_services.rs`:
- Around line 2998-3022: The admin write paths allow any caller that passes
authorize_admin() to create or assign an Owner role, so add a role-ceiling check
before the mutations in create_admin_user and set_admin_user_role. Use the
existing AdminUserRole and role conversion helpers (role_to_identity /
role_from_identity) to compare the caller’s admin role against the requested
target role, and reject any attempt to mint or promote Owner unless the caller
is explicitly allowed. Keep the guard close to the admin_users.create_user and
role update logic so both user creation and role changes enforce the same
hierarchy rule.
- Around line 2915-2943: The last-admin guard in ensure_not_last_admin is still
racy because it does a tenant scan after a per-user CAS path, so concurrent
demotions/deletes can both succeed. Move the protection into a single atomic
tenant-scoped identity-layer operation, or serialize the read+write with a
tenant-level lock around the whole mutation path. Update the
ensure_not_last_admin flow and the related admin_users
count_active_admins/cas_update interaction so the check and write cannot
interleave across replicas.

In `@crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs`:
- Around line 96-109: AdminUserError is a plain enum without
Display/std::error::Error support, which makes it inconsistent with the rest of
the error types. Update the AdminUserError type in admin_users.rs to use
thiserror with per-variant #[error(...)] messages while keeping the coarse
taxonomy intact, and preserve the existing variants NotFound, Unavailable, and
Internal. If needed, adjust any call sites in the admin user facade/adapter that
rely on formatting or conversion so they continue mapping errors with context
cleanly.

In `@crates/ironclaw_product_workflow/tests/reborn_services_contract.rs`:
- Around line 11626-11633: The `delete_user` coverage is missing the sole-admin
case, so add a test alongside the existing `demote_user` and `suspend_user`
last-admin checks that exercises the facade’s delete path for the final
remaining admin. Update the `reborn_services_contract` test flow to attempt
deleting the only admin through the facade and assert the last-admin guard
blocks it, using the same setup/helpers already used around `delete_user`,
`demote_user`, and `suspend_user`.

In `@crates/ironclaw_reborn_cli/src/commands/serve.rs`:
- Around line 201-213: The admin bearer minting setup in serve() is creating
admin_session_store from session_signing_secret unconditionally, but the 32-byte
entropy check is still only gated by sso_startup.is_some(). Make the
entropy-floor validation unconditional, or move it directly beside
with_admin_api_token_minter(...) so SignedSessionTokenMinter cannot be wired
unless the operator secret is strong enough.

In `@crates/ironclaw_reborn_composition/src/admin_secrets.rs`:
- Around line 77-104: The admin secret mutation path in
AdminSecretProvisioner::store_for is being reached from admin_user_directory
without verifying that the target user belongs to the requested tenant. Update
the list_secrets, put_secret, and delete_secret flows to reuse
tenant_scoped_user or otherwise check the fetched RebornUser.tenant_id before
calling AdminSecretProvisioner, so secret reads/writes/deletes are only allowed
for the owning tenant.

In `@crates/ironclaw_reborn_composition/src/admin_token.rs`:
- Line 20: The admin token mint port currently returns String from mint, which
drops typed error context and violates the Rust error conventions used here.
Update the admin_token::mint signature to return a small thiserror-based error
type instead of String, then ensure the adapter layer maps that error into
AdminUserError with context; use the mint method and AdminUserError as the key
symbols when updating the composition boundary.

In `@crates/ironclaw_reborn_composition/src/admin_user_directory.rs`:
- Around line 104-125: The admin user flow in create_user currently persists the
user before token minting, then returns AdminUserError::Internal on mint
failure, leaving a tokenless account behind. Update the logic around create_user
and token_minter.mint so failures are compensated with a rollback/deletion of
the newly created user (or otherwise avoid committing the user until mint
succeeds), and keep the error mapping/logging behavior in place for the mint
failure path.
- Around line 132-180: The tenant-scoped mutation methods in AdminUserDirectory
are relying on the facade precheck and ignoring the tenant argument, which can
allow cross-tenant updates if that precheck is bypassed. Update update_profile,
set_status, and set_role to enforce tenant ownership inside the port before
calling directory.update_profile, directory.update_status, and
directory.update_role. Use the existing user lookup/validation path in this
module (or add an equivalent tenant match guard) so each mutation verifies the
UserId belongs to the provided TenantId before applying changes.

In `@crates/ironclaw_reborn_composition/src/lib.rs`:
- Around line 797-804: The MountGrant in the tenant-shared mount setup is too
broad because it gives list/delete to the entire /tenant-shared subtree via
invocation_mount_view callers. Narrow this by keeping the existing
/tenant-shared grant least-privileged and adding a separate admin-only grant or
mount view for /tenant-shared/reborn-identity to cover the identity-store admin
operations. Update the mount-building logic around MountGrant::new,
MountAlias::new, and MountPermissions::read_write_list_delete so only the
identity-specific path gets the extra permissions.

In `@crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs`:
- Around line 465-501: The last-admin protection test in
admin_last_admin_protection_over_http only covers demotion and suspension, not
the delete path. Extend this test using AdminApiDriver::delete_user on the sole
admin and assert it returns StatusCode::CONFLICT, matching the existing
last_admin behavior checked via set_role and set_status. If delete_user is meant
to bypass ensure_not_last_admin, add a clear comment or documentation in
reborn_services.rs explaining that exemption.

In `@crates/ironclaw_reborn_identity/CONTRACT.md`:
- Around line 50-54: Update the persisted-records table in CONTRACT.md so the
`StoredUser` row matches the actual `StoredUser` schema used by
`filesystem_store/directory.rs`. Add the newly persisted fields (`status`,
`role`, `created_by`, `last_login_at`, `tenant_id`, `metadata`) to the
`StoredUser` field list, and keep the row aligned with the record definition so
the contract documentation reflects the current storage shape.

In `@crates/ironclaw_reborn_identity/src/filesystem_store/record.rs`:
- Around line 34-38: The legacy tenant handling for `tenant_id` is too
permissive because `None` currently gets treated as valid for every tenant.
Update the `RebornUserDirectory::list_users` path and the `record.rs`
`tenant_id` fallback so `None` is only accepted when the deployment is truly
single-tenant; otherwise fail closed or exclude those rows from admin
enumeration. If needed, gate the `Option<String>` fallback with an explicit
single-tenant check instead of unconditionally mapping `None` to true.

In `@crates/ironclaw_webui_v2/src/handlers.rs`:
- Around line 196-205: The `parse_admin_user_id` helper is discarding the
concrete `UserId::new(raw)` validation error by using `map_err(|_| ...)`. Update
this mapping so the error binding from `UserId::new` is preserved either by
carrying the cause into the `WebUiInboundValidationError`/`RebornServicesError`
chain or by logging the bound error before converting it to `WebUiV2HttpError`.
Keep the existing `parse_admin_user_id` flow and `UserId::new` call, but avoid
substituting a generic `InvalidId` without the original reason.

In `@crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js`:
- Around line 46-62: `updateAdminUser` currently returns immediately when
`payload.role` is present, so any accompanying `display_name` or `metadata` is
silently ignored. Update the branching in `updateAdminUser` to either reject
mixed payloads up front or send all supported fields through the appropriate
request path, and keep the behavior explicit rather than dropping data. Use the
existing `apiFetch`, `normalizeUser`, and the role/PATCH routes in
`admin-api.js` to locate and fix the mixed-payload handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c793fdfd-b35b-4e26-868c-5b54c1227884

📥 Commits

Reviewing files that changed from the base of the PR and between a8053cc and f4fda26.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (36)
  • .claude/rules/discovery-claims.md
  • crates/ironclaw_product_workflow/src/lib.rs
  • crates/ironclaw_product_workflow/src/reborn_services.rs
  • crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn_cli/src/commands/serve.rs
  • crates/ironclaw_reborn_composition/Cargo.toml
  • crates/ironclaw_reborn_composition/src/admin_secrets.rs
  • crates/ironclaw_reborn_composition/src/admin_token.rs
  • crates/ironclaw_reborn_composition/src/admin_user_directory.rs
  • crates/ironclaw_reborn_composition/src/factory.rs
  • crates/ironclaw_reborn_composition/src/lib.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/runtime_input.rs
  • crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs
  • crates/ironclaw_reborn_composition/src/webui.rs
  • crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs
  • crates/ironclaw_reborn_identity/CONTRACT.md
  • crates/ironclaw_reborn_identity/src/filesystem_store.rs
  • crates/ironclaw_reborn_identity/src/filesystem_store/directory.rs
  • crates/ironclaw_reborn_identity/src/filesystem_store/paths.rs
  • crates/ironclaw_reborn_identity/src/filesystem_store/record.rs
  • crates/ironclaw_reborn_identity/src/filesystem_store/tests.rs
  • crates/ironclaw_reborn_identity/src/lib.rs
  • crates/ironclaw_reborn_identity/src/user_directory.rs
  • crates/ironclaw_reborn_webui_ingress/src/lib.rs
  • crates/ironclaw_reborn_webui_ingress/src/signed_session_login.rs
  • crates/ironclaw_webui_v2/src/descriptors.rs
  • crates/ironclaw_webui_v2/src/handlers.rs
  • crates/ironclaw_webui_v2/src/lib.rs
  • crates/ironclaw_webui_v2/src/router.rs
  • crates/ironclaw_webui_v2/static/js/app/routes.js
  • crates/ironclaw_webui_v2/static/js/pages/admin/admin-page.js
  • crates/ironclaw_webui_v2/static/js/pages/admin/hooks/useAdminUsers.js
  • crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js
  • crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs

Comment thread crates/ironclaw_product_workflow/src/reborn_services.rs
Comment on lines +2998 to +3022
async fn create_admin_user(
&self,
caller: WebUiAuthenticatedCaller,
request: RebornAdminCreateUserRequest,
) -> Result<RebornAdminUserCreatedResponse, RebornServicesError> {
self.authorize_admin(&caller).await?;
let created = self
.admin_users
.create_user(
&caller.tenant_id,
&caller.user_id,
AdminCreateUserFields {
email: request.email,
display_name: request.display_name,
role: request.role,
},
)
.await
.map_err(map_admin_user_error)?;
Ok(RebornAdminUserCreatedResponse {
user: created.record,
// Exposed exactly once, here. The DTO carries it in no other path.
api_token: created.api_token.expose_secret().to_string(),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- reborn_services.rs around target lines ---'
sed -n '2960,3095p' crates/ironclaw_product_workflow/src/reborn_services.rs

echo
echo '--- search AdminUserRole and admin auth helpers ---'
rg -n "enum AdminUserRole|impl AdminUserRole|is_admin\(|authorize_admin|set_admin_user_role|create_admin_user|Owner|owner" crates/ironclaw_product_workflow/src/reborn_services.rs crates/ironclaw_product_workflow/src -g '*.rs'

echo
echo '--- broader search for owner-only checks in product workflow ---'
rg -n "Owner|is_owner|role.*Owner|AdminUserRole::Owner|AdminUserRole" crates/ironclaw_product_workflow/src crates/ -g '*.rs'

Repository: nearai/ironclaw

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- admin_users.rs role contract ---'
sed -n '1,220p' crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs

echo
echo '--- role-sensitive admin-user service methods and tests ---'
rg -n "set_role\(|create_user\(|ensure_not_last_admin|AdminUserRole::Owner|AdminUserRole::Admin|AdminUserRole::Member|is_admin\(" \
  crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs \
  crates/ironclaw_product_workflow/src/reborn_services.rs \
  crates/ironclaw_product_workflow/src -g '*.rs'

echo
echo '--- tests mentioning owner/admin role transitions ---'
rg -n "Owner.*Admin|Admin.*Owner|create_admin_user|set_admin_user_role|set_role" \
  crates/ironclaw_product_workflow/src -g '*.rs' | head -n 120

Repository: nearai/ironclaw

Length of output: 12389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- owner/admin role usage in reborn composition identity adapter ---'
rg -n "RebornUserRole::Owner|AdminUserRole::Owner|role_to_identity|role_from_identity|Owner.*admin|admin.*Owner" \
  crates/ironclaw_reborn_composition/src/admin_user_directory.rs \
  crates/ironclaw_reborn_composition/src -g '*.rs'

echo
echo '--- admin-user directory mapping around role conversions ---'
sed -n '240,340p' crates/ironclaw_reborn_composition/src/admin_user_directory.rs

echo
echo '--- search for owner-specific admin-user checks across product workflow ---'
rg -n "AdminUserRole::Owner|role == .*Owner|is_owner\(|owner access required|owner-only" \
  crates/ironclaw_product_workflow/src/reborn_services.rs \
  crates/ironclaw_product_workflow/src/reborn_services \
  crates/ironclaw_reborn_composition/src -g '*.rs'

Repository: nearai/ironclaw

Length of output: 5673


No role-hierarchy cap on admin-user writes. authorize_admin() only gates on is_admin(), but AdminUserRole::Owner is still a distinct stored role (role_to_identity / role_from_identity). That lets any Admin mint or promote an Owner in create_admin_user and set_admin_user_role. Add a caller-role ceiling before those mutations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_product_workflow/src/reborn_services.rs` around lines 2998 -
3022, The admin write paths allow any caller that passes authorize_admin() to
create or assign an Owner role, so add a role-ceiling check before the mutations
in create_admin_user and set_admin_user_role. Use the existing AdminUserRole and
role conversion helpers (role_to_identity / role_from_identity) to compare the
caller’s admin role against the requested target role, and reject any attempt to
mint or promote Owner unless the caller is explicitly allowed. Keep the guard
close to the admin_users.create_user and role update logic so both user creation
and role changes enforce the same hierarchy rule.

Comment on lines +96 to +109
/// Failure modes of the admin user port. Deliberately coarse and free of
/// backend detail — the composition adapter maps identity/secret errors into
/// these, and the facade maps these into the sanitized `RebornServicesError`
/// wire taxonomy. Authorization and last-admin protection are enforced in the
/// facade, not here, so they are not modeled as port errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminUserError {
/// The targeted user id has no record.
NotFound,
/// A transient backend failure; the caller may retry.
Unavailable,
/// A backend inconsistency or unexpected failure; not retryable.
Internal,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

AdminUserError doesn't use thiserror.

RebornIdentityError in the identity crate is a proper thiserror enum with #[error(...)] messages, but this port-level error is a bare Debug/Copy enum with no Display/std::error::Error impl. As per coding guidelines, "Use thiserror for error types in error.rs" and more broadly "Use thiserror for Rust error types and map errors with context" for **/*.rs. The "deliberately coarse, free of backend detail" design goal doesn't require dropping Error/Display — #[error("user not found")] etc. keeps it coarse while staying consistent with the rest of the stack.

♻️ Suggested fix
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
 pub enum AdminUserError {
     /// The targeted user id has no record.
+    #[error("user not found")]
     NotFound,
     /// A transient backend failure; the caller may retry.
+    #[error("admin user service temporarily unavailable")]
     Unavailable,
     /// A backend inconsistency or unexpected failure; not retryable.
+    #[error("internal admin user service error")]
     Internal,
 }

As per coding guidelines, "Use thiserror for Rust error types and map errors with context."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Failure modes of the admin user port. Deliberately coarse and free of
/// backend detail — the composition adapter maps identity/secret errors into
/// these, and the facade maps these into the sanitized `RebornServicesError`
/// wire taxonomy. Authorization and last-admin protection are enforced in the
/// facade, not here, so they are not modeled as port errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminUserError {
/// The targeted user id has no record.
NotFound,
/// A transient backend failure; the caller may retry.
Unavailable,
/// A backend inconsistency or unexpected failure; not retryable.
Internal,
}
/// Failure modes of the admin user port. Deliberately coarse and free of
/// backend detail — the composition adapter maps identity/secret errors into
/// these, and the facade maps these into the sanitized `RebornServicesError`
/// wire taxonomy. Authorization and last-admin protection are enforced in the
/// facade, not here, so they are not modeled as port errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AdminUserError {
/// The targeted user id has no record.
#[error("user not found")]
NotFound,
/// A transient backend failure; the caller may retry.
#[error("admin user service temporarily unavailable")]
Unavailable,
/// A backend inconsistency or unexpected failure; not retryable.
#[error("internal admin user service error")]
Internal,
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_product_workflow/src/reborn_services/admin_users.rs` around
lines 96 - 109, AdminUserError is a plain enum without Display/std::error::Error
support, which makes it inconsistent with the rest of the error types. Update
the AdminUserError type in admin_users.rs to use thiserror with per-variant
#[error(...)] messages while keeping the coarse taxonomy intact, and preserve
the existing variants NotFound, Unavailable, and Internal. If needed, adjust any
call sites in the admin user facade/adapter that rely on formatting or
conversion so they continue mapping errors with context cleanly.

Source: Coding guidelines

Comment thread crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
Comment thread crates/ironclaw_reborn_cli/src/commands/serve.rs
Comment on lines +465 to +501
#[tokio::test]
async fn admin_last_admin_protection_over_http() {
let harness = build_admin_harness().await;
let operator = AdminApiDriver::new(harness.router.clone(), OPERATOR_TOKEN);

// One admin user record → it is the sole active admin.
let (_, sole) = operator.create_user(None, "Sole Admin", "admin").await;
let sole_id = user_id_of(&sole);

let (status, demote) = operator.set_role(&sole_id, "member").await;
assert_eq!(
status,
StatusCode::CONFLICT,
"demoting the sole admin is blocked"
);
assert_eq!(
demote["field"].as_str(),
Some("last_admin"),
"the block carries the stable last_admin marker"
);
let (status, _) = operator.set_status(&sole_id, "suspended").await;
assert_eq!(
status,
StatusCode::CONFLICT,
"suspending the sole admin is blocked"
);

// A second admin removes the protection.
let (_, second) = operator.create_user(None, "Second Admin", "admin").await;
let second_id = user_id_of(&second);
let (status, _) = operator.set_role(&second_id, "member").await;
assert_eq!(
status,
StatusCode::OK,
"demoting one of two admins is allowed"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Last-admin protection test doesn't cover the delete path.

This test exercises demote (set_role) and suspend (set_status) for the sole admin, but never delete_user. Deleting the sole admin is the most destructive of the three "drop to zero admins" mutations and is the one case the flagship lifecycle test (line 454-462) never applies to an admin — it only deletes a member. If ensure_not_last_admin (per the reborn_services.rs snippet) is also invoked from the delete path, add:

let (status, _) = operator.delete_user(&sole_id).await;
assert_eq!(status, StatusCode::CONFLICT, "deleting the sole admin is blocked");

If delete intentionally bypasses this guard, that's worth a comment in reborn_services.rs documenting why deletion is exempt from last-admin protection while demote/suspend are not.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs` around lines 465 -
501, The last-admin protection test in admin_last_admin_protection_over_http
only covers demotion and suspension, not the delete path. Extend this test using
AdminApiDriver::delete_user on the sole admin and assert it returns
StatusCode::CONFLICT, matching the existing last_admin behavior checked via
set_role and set_status. If delete_user is meant to bypass
ensure_not_last_admin, add a clear comment or documentation in
reborn_services.rs explaining that exemption.

Comment on lines +50 to +54
| Record | Path (opaque segments base64url-encoded) | Fields |
|---|---|---|
| `StoredUser` — the canonical **user profile** | `…/users/{user_id}.json` | `email`, `display_name`, `created_at`, `updated_at` |
| `StoredExternalIdentity` — one bound external login | `…/external/{tenant}/{surface}/{provider}/{instance}/{subject}.json` | `user_id`, `email`, `email_verified`, `created_at` |
| `StoredVerifiedEmailIndex` — cross-provider link | `…/verified-email/{tenant}/{lower_email}.json` | `user_id` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Persisted-records table is stale for the new StoredUser fields.

The table row for StoredUser still lists only email, display_name, created_at, updated_at — but this PR adds status, role, created_by, last_login_at, tenant_id, and metadata to the same record (see filesystem_store/directory.rs StoredUser usage). This is exactly the "Stale Comments After Refactors" trap: a contract doc claiming a narrower schema than the code persists is a bug report waiting to happen for the next reader who trusts the table.

📝 Suggested fix
-| `StoredUser` — the canonical **user profile** | `…/users/{user_id}.json` | `email`, `display_name`, `created_at`, `updated_at` |
+| `StoredUser` — the canonical **user profile** | `…/users/{user_id}.json` | `email`, `display_name`, `created_at`, `updated_at`, `status`, `role`, `created_by`, `last_login_at`, `tenant_id`, `metadata` |

Based on learnings, "Doc strings and inline comments are part of the contract... update or delete them in the same change" (Stale Comments After Refactors, .claude/rules).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Record | Path (opaque segments base64url-encoded) | Fields |
|---|---|---|
| `StoredUser` — the canonical **user profile** | `…/users/{user_id}.json` | `email`, `display_name`, `created_at`, `updated_at` |
| `StoredExternalIdentity` — one bound external login | `…/external/{tenant}/{surface}/{provider}/{instance}/{subject}.json` | `user_id`, `email`, `email_verified`, `created_at` |
| `StoredVerifiedEmailIndex` — cross-provider link | `…/verified-email/{tenant}/{lower_email}.json` | `user_id` |
| Record | Path (opaque segments base64url-encoded) | Fields |
|---|---|---|
| `StoredUser` — the canonical **user profile** | `…/users/{user_id}.json` | `email`, `display_name`, `created_at`, `updated_at`, `status`, `role`, `created_by`, `last_login_at`, `tenant_id`, `metadata` |
| `StoredExternalIdentity` — one bound external login | `…/external/{tenant}/{surface}/{provider}/{instance}/{subject}.json` | `user_id`, `email`, `email_verified`, `created_at` |
| `StoredVerifiedEmailIndex` — cross-provider link | `…/verified-email/{tenant}/{lower_email}.json` | `user_id` |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_identity/CONTRACT.md` around lines 50 - 54, Update the
persisted-records table in CONTRACT.md so the `StoredUser` row matches the
actual `StoredUser` schema used by `filesystem_store/directory.rs`. Add the
newly persisted fields (`status`, `role`, `created_by`, `last_login_at`,
`tenant_id`, `metadata`) to the `StoredUser` field list, and keep the row
aligned with the record definition so the contract documentation reflects the
current storage shape.

Source: Path instructions

Comment on lines +34 to +38
/// Tenant that owns this user. `None` on records written before the admin
/// surface existed; enumeration treats `None` as the deployment's single
/// configured tenant (see `RebornUserDirectory::list_users`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) tenant_id: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant files and map their structure before reading slices.
git ls-files 'crates/ironclaw_reborn_identity/src/filesystem_store/*' 'crates/ironclaw_reborn_identity/src/**/directory.rs' 'CLAUDE.md' '.claude/**' | sed -n '1,200p'

echo
echo "== outline: record.rs =="
ast-grep outline crates/ironclaw_reborn_identity/src/filesystem_store/record.rs --view expanded

echo
echo "== outline: directory.rs =="
ast-grep outline crates/ironclaw_reborn_identity/src/filesystem_store/directory.rs --view expanded

echo
echo "== relevant lines in record.rs =="
sed -n '1,120p' crates/ironclaw_reborn_identity/src/filesystem_store/record.rs

echo
echo "== relevant lines in directory.rs =="
sed -n '1,220p' crates/ironclaw_reborn_identity/src/filesystem_store/directory.rs

echo
echo "== repo invariant mentions =="
rg -n 'Everything Goes Through Tools|Fail loud|LLM data is never deleted|tenant|multi-tenant|single configured tenant' CLAUDE.md .claude crates/ironclaw_reborn_identity/src -S

Repository: nearai/ironclaw

Length of output: 35440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' crates/ironclaw_reborn_identity/src/user_directory.rs
echo
sed -n '240,360p' crates/ironclaw_reborn_identity/src/filesystem_store/directory.rs
echo
sed -n '790,880p' crates/ironclaw_reborn_identity/src/filesystem_store/tests.rs

Repository: nearai/ironclaw

Length of output: 13920


Gate legacy tenant_id: None rows behind a real single-tenant check

None => true here, so pre-admin users are surfaced in every tenant’s admin list. The admin directory contract only treats those rows as safe in single-tenant deployments; either backfill tenant IDs or fail closed once the deployment is multi-tenant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_identity/src/filesystem_store/record.rs` around lines
34 - 38, The legacy tenant handling for `tenant_id` is too permissive because
`None` currently gets treated as valid for every tenant. Update the
`RebornUserDirectory::list_users` path and the `record.rs` `tenant_id` fallback
so `None` is only accepted when the deployment is truly single-tenant; otherwise
fail closed or exclude those rows from admin enumeration. If needed, gate the
`Option<String>` fallback with an explicit single-tenant check instead of
unconditionally mapping `None` to true.

Comment on lines +196 to +205
/// Parse a `{user_id}` path segment into a `UserId`, mapping a malformed value
/// to a sanitized `400 invalid_request` before the facade is touched.
fn parse_admin_user_id(raw: String) -> Result<UserId, WebUiV2HttpError> {
UserId::new(raw).map_err(|_| {
WebUiV2HttpError::from(RebornServicesError::from(WebUiInboundValidationError::new(
"user_id",
WebUiInboundValidationCode::InvalidId,
)))
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

.map_err(|_| ...) drops the UserId::new validation cause.

The closure discards the error binding from UserId::new(raw) and substitutes a generic InvalidId error with no logging of the original reason. As per coding guidelines: "Do not use .map_err(|_| OtherError) or any closure that discards its error binding and substitutes a generic error; carry the cause instead or log the bound error before mapping."

🩹 Proposed fix — log the bound error before mapping
 fn parse_admin_user_id(raw: String) -> Result<UserId, WebUiV2HttpError> {
-    UserId::new(raw).map_err(|_| {
+    UserId::new(raw).map_err(|error| {
+        tracing::debug!(target = "ironclaw::webui_v2::admin", %error, "invalid admin user_id path segment");
         WebUiV2HttpError::from(RebornServicesError::from(WebUiInboundValidationError::new(
             "user_id",
             WebUiInboundValidationCode::InvalidId,
         )))
     })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Parse a `{user_id}` path segment into a `UserId`, mapping a malformed value
/// to a sanitized `400 invalid_request` before the facade is touched.
fn parse_admin_user_id(raw: String) -> Result<UserId, WebUiV2HttpError> {
UserId::new(raw).map_err(|_| {
WebUiV2HttpError::from(RebornServicesError::from(WebUiInboundValidationError::new(
"user_id",
WebUiInboundValidationCode::InvalidId,
)))
})
}
/// Parse a `{user_id}` path segment into a `UserId`, mapping a malformed value
/// to a sanitized `400 invalid_request` before the facade is touched.
fn parse_admin_user_id(raw: String) -> Result<UserId, WebUiV2HttpError> {
UserId::new(raw).map_err(|error| {
tracing::debug!(target = "ironclaw::webui_v2::admin", %error, "invalid admin user_id path segment");
WebUiV2HttpError::from(RebornServicesError::from(WebUiInboundValidationError::new(
"user_id",
WebUiInboundValidationCode::InvalidId,
)))
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui_v2/src/handlers.rs` around lines 196 - 205, The
`parse_admin_user_id` helper is discarding the concrete `UserId::new(raw)`
validation error by using `map_err(|_| ...)`. Update this mapping so the error
binding from `UserId::new` is preserved either by carrying the cause into the
`WebUiInboundValidationError`/`RebornServicesError` chain or by logging the
bound error before converting it to `WebUiV2HttpError`. Keep the existing
`parse_admin_user_id` flow and `UserId::new` call, but avoid substituting a
generic `InvalidId` without the original reason.

Source: Coding guidelines

Comment on lines +46 to 62
export async function updateAdminUser(id, payload) {
if (payload && Object.prototype.hasOwnProperty.call(payload, "role")) {
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
method: "POST",
body: { role: payload.role },
});
return normalizeUser(response?.user);
}
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
method: "PATCH",
body: {
display_name: payload?.display_name,
metadata: payload?.metadata,
},
});
return normalizeUser(response?.user);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

updateAdminUser silently drops non-role fields when role is present.

If payload ever contains role together with display_name/metadata, the function returns after the /role call and never reaches the PATCH branch — those fields are silently dropped, not merged or rejected. The adjacent comment (lines 43-45) claims routing "keeps the client honest," but the current branching does the opposite for any mixed payload: it discards data with no error, warning, or partial-success signal. Currently masked because the caller only ever sends { role } alone, but that's an external invariant this file can't enforce.

🐛 Proposed fix: don't let a mixed payload silently lose fields
 export async function updateAdminUser(id, payload) {
-  if (payload && Object.prototype.hasOwnProperty.call(payload, "role")) {
-    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
-      method: "POST",
-      body: { role: payload.role },
-    });
-    return normalizeUser(response?.user);
-  }
-  const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
-    method: "PATCH",
-    body: {
-      display_name: payload?.display_name,
-      metadata: payload?.metadata,
-    },
-  });
-  return normalizeUser(response?.user);
+  const hasRole = payload && Object.prototype.hasOwnProperty.call(payload, "role");
+  const hasProfileFields =
+    payload && (payload.display_name !== undefined || payload.metadata !== undefined);
+
+  let latest;
+  if (hasRole) {
+    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
+      method: "POST",
+      body: { role: payload.role },
+    });
+    latest = normalizeUser(response?.user);
+  }
+  if (hasProfileFields) {
+    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
+      method: "PATCH",
+      body: { display_name: payload?.display_name, metadata: payload?.metadata },
+    });
+    latest = normalizeUser(response?.user);
+  }
+  return latest;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function updateAdminUser(id, payload) {
if (payload && Object.prototype.hasOwnProperty.call(payload, "role")) {
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
method: "POST",
body: { role: payload.role },
});
return normalizeUser(response?.user);
}
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
method: "PATCH",
body: {
display_name: payload?.display_name,
metadata: payload?.metadata,
},
});
return normalizeUser(response?.user);
}
export async function updateAdminUser(id, payload) {
const hasRole = payload && Object.prototype.hasOwnProperty.call(payload, "role");
const hasProfileFields =
payload && (payload.display_name !== undefined || payload.metadata !== undefined);
let latest;
if (hasRole) {
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
method: "POST",
body: { role: payload.role },
});
latest = normalizeUser(response?.user);
}
if (hasProfileFields) {
const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
method: "PATCH",
body: { display_name: payload?.display_name, metadata: payload?.metadata },
});
latest = normalizeUser(response?.user);
}
return latest;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js` around lines
46 - 62, `updateAdminUser` currently returns immediately when `payload.role` is
present, so any accompanying `display_name` or `metadata` is silently ignored.
Update the branching in `updateAdminUser` to either reject mixed payloads up
front or send all supported fields through the appropriate request path, and
keep the behavior explicit rather than dropping data. Use the existing
`apiFetch`, `normalizeUser`, and the role/PATCH routes in `admin-api.js` to
locate and fix the mixed-payload handling.

@henrypark133

Copy link
Copy Markdown
Collaborator

Follow-up on the integration-coverage note above — I verified this is writable today by building and running the three scenarios against this branch (all green, 0.10s):

  • tests/integration/webui_v2_product_api.rs already hand-builds RebornServices::new(thread_service, coordinator) (see its header comment) — .with_admin_user_service(Arc::new(fake)) composes with that directly. No RebornRuntime/token-minter needed at this tier.
  • The AdminUserService port is pub from ironclaw_product_workflow with tenant as an explicit arg, so a small local fake (essentially your FakeAdminUsers from reborn_services_contract.rs:11523, ported — it isn't importable cross-crate) covers all three scenarios:
    1. non-admin caller → 403 (note: the default RejectingAdminUserService gives 503, so the wired-but-member fake is what proves the real authz arm),
    2. admin CRUD round-trip through the real routes,
    3. cross-tenant isolation — WebUiAuthenticatedCaller::new takes a raw TenantId, so two callers in different tenants over one shared fake proves admin-in-tenant-A can't list tenant-B's users. This is the one scenario currently untested at any layer above the store.
  • Only missing harness piece: webui_mount.rs lacks patch_json/delete_json (needed for PATCH/DELETE /admin/users/:id) — ~35 lines mirroring post_json.

Happy to push the mount-helper enabler + a ready tests/integration/admin_users.rs skeleton onto this branch if that helps — the port cost is minutes, not hours.

@railway-app

railway-app Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-5779 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw 🕒 Building (View Logs) Web Jul 8, 2026 at 10:31 am

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>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 7, 2026 17:55 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js (1)

43-62: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

updateAdminUser still silently drops non-role fields when role is present.

Same issue flagged previously: if payload carries role together with display_name/metadata, the function returns after the /role call and the PATCH branch is never reached — those fields vanish with no error or warning, directly contradicting the adjacent comment's claim that routing "keeps the client honest."

🐛 Proposed fix: don't let a mixed payload silently lose fields
 export async function updateAdminUser(id, payload) {
-  if (payload && Object.prototype.hasOwnProperty.call(payload, "role")) {
-    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
-      method: "POST",
-      body: JSON.stringify({ role: payload.role }),
-    });
-    return normalizeUser(response?.user);
-  }
-  const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
-    method: "PATCH",
-    body: JSON.stringify({
-      display_name: payload?.display_name,
-      metadata: payload?.metadata,
-    }),
-  });
-  return normalizeUser(response?.user);
+  const hasRole = payload && Object.prototype.hasOwnProperty.call(payload, "role");
+  const hasProfileFields =
+    payload && (payload.display_name !== undefined || payload.metadata !== undefined);
+
+  let latest;
+  if (hasRole) {
+    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}/role`, {
+      method: "POST",
+      body: JSON.stringify({ role: payload.role }),
+    });
+    latest = normalizeUser(response?.user);
+  }
+  if (hasProfileFields) {
+    const response = await apiFetch(`${ADMIN_BASE}/users/${encodeURIComponent(id)}`, {
+      method: "PATCH",
+      body: JSON.stringify({ display_name: payload?.display_name, metadata: payload?.metadata }),
+    });
+    latest = normalizeUser(response?.user);
+  }
+  return latest;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js` around lines
43 - 62, updateAdminUser currently returns immediately after handling
payload.role, so any accompanying display_name or metadata fields are silently
ignored. Update the routing logic in updateAdminUser to either reject mixed
payloads explicitly or send both updates in a way that preserves all fields, and
make sure the role-specific /users/:id/role path does not bypass the PATCH flow
for non-role changes. Use the existing normalizeUser, apiFetch, and ADMIN_BASE
handling to keep the behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/e2e/scenarios/test_admin_api.py`:
- Line 35: The parameterless pytest fixtures use unnecessary empty parentheses
on the decorator, which Ruff PT001 flags. Update the fixture decorators on the
affected fixture functions to use the plain `@pytest.fixture` form instead of
`@pytest.fixture`(), and apply the same change to each matching fixture in this
test module.

---

Duplicate comments:
In `@crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js`:
- Around line 43-62: updateAdminUser currently returns immediately after
handling payload.role, so any accompanying display_name or metadata fields are
silently ignored. Update the routing logic in updateAdminUser to either reject
mixed payloads explicitly or send both updates in a way that preserves all
fields, and make sure the role-specific /users/:id/role path does not bypass the
PATCH flow for non-role changes. Use the existing normalizeUser, apiFetch, and
ADMIN_BASE handling to keep the behavior consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cd2bb644-9a90-4ade-b9a9-9db8ef202080

📥 Commits

Reviewing files that changed from the base of the PR and between f4fda26 and aae2238.

📒 Files selected for processing (4)
  • crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.js
  • crates/ironclaw_webui_v2/static/js/pages/admin/lib/admin-api.test.js
  • tests/e2e/reborn_coverage_tests.txt
  • tests/e2e/scenarios/test_admin_api.py

ADMIN_BASE = "/api/webchat/v2/admin"


@pytest.fixture()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drop the empty parens on @pytest.fixture().

Ruff (PT001) flags unnecessary parens on parameterless fixture decorators.

-@pytest.fixture()
+@pytest.fixture
 async def admin_client(reborn_v2_server):
-@pytest.fixture()
+@pytest.fixture
 async def test_user(admin_client):

Also applies to: 46-46

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 35-35: Use @pytest.fixture over @pytest.fixture()

Remove parentheses

(PT001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/scenarios/test_admin_api.py` at line 35, The parameterless pytest
fixtures use unnecessary empty parentheses on the decorator, which Ruff PT001
flags. Update the fixture decorators on the affected fixture functions to use
the plain `@pytest.fixture` form instead of `@pytest.fixture`(), and apply the same
change to each matching fixture in this test module.

Source: Linters/SAST tools

`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>
Copilot AI review requested due to automatic review settings July 7, 2026 18:22
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 7, 2026 18:23 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_reborn_cli/src/commands/webui_auth.rs`:
- Around line 76-98: The no-SSO branch in webui_auth.rs now constructs a
CompositeAuthenticator with a SessionAuthenticator, but the test only checks
public_mount; update the relevant webui auth test to also exercise the minted
session-bearer path. Add an assertion that a session token created through the
no-SSO setup authenticates successfully via the WebuiAuthenticator, using the
same no-SSO/CompositeAuthenticator flow so the admin-API bearer case is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c5666c55-401f-4673-961a-00d51fbe6a52

📥 Commits

Reviewing files that changed from the base of the PR and between aae2238 and acdb0c7.

📒 Files selected for processing (3)
  • crates/ironclaw_reborn_cli/src/commands/webui_auth.rs
  • crates/ironclaw_reborn_webui_ingress/src/lib.rs
  • crates/ironclaw_reborn_webui_ingress/src/signed_session_login.rs

Comment on lines +76 to 98
// No SSO providers: no public login routes, and no SSO logins to seed
// local trigger access for (bootstrap config is unused here). But the
// serve layer *always* wires the admin-API token minter, which mints
// signed **session** tokens (the user-create bearer). Those validate
// only through a `SessionAuthenticator` over the same signed store —
// absent it, an admin-created user's API token would 401 on every
// request (regression caught by `tests/e2e/scenarios/test_admin_api.py`).
// Compose the env-bearer (operator) authenticator with a session
// authenticator over that store so minted tokens work without SSO;
// operator capabilities still follow the env token only, so the session
// bearer stays non-operator.
let session_authenticator: Arc<dyn WebuiAuthenticator> = Arc::new(
SessionAuthenticator::new(signed_session_store(&session_signing_secret, &tenant_id)),
);
let authenticator: Arc<dyn WebuiAuthenticator> = Arc::new(CompositeAuthenticator::new(
session_authenticator,
env_authenticator,
));
return Ok(WebuiAuthSurface {
authenticator: env_authenticator,
authenticator,
public_mount: None,
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify minter and authenticator share secret+tenant, and inspect the no-SSO test.
rg -nP -C4 'from_operator_secret|signed_session_store|AdminApiTokenMinter' crates/ironclaw_reborn_cli/src/commands/serve.rs crates/ironclaw_reborn_composition/src/admin_token.rs
rg -nP -C6 'no_sso_keeps_env_authenticator' crates/ironclaw_reborn_cli/src/commands/webui_auth.rs

Repository: nearai/ironclaw

Length of output: 3593


🏁 Script executed:

#!/bin/bash
sed -n '60,110p' crates/ironclaw_reborn_cli/src/commands/webui_auth.rs
printf '\n---\n'
sed -n '230,270p' crates/ironclaw_reborn_cli/src/commands/webui_auth.rs

Repository: nearai/ironclaw

Length of output: 4546


🏁 Script executed:

#!/bin/bash
rg -n "minted session|authenticator.authenticate|signed_session_store|admin_api" crates/ironclaw_reborn_cli/src/commands/webui_auth.rs crates/ironclaw_reborn_cli/src/commands crates/ironclaw_reborn_composition/src tests

Repository: nearai/ironclaw

Length of output: 2548


Cover the no-SSO session-bearer path crates/ironclaw_reborn_cli/src/commands/webui_auth.rs:234 The test still only asserts public_mount.is_none(), but this branch now builds a CompositeAuthenticator. Add a token-level assertion that a minted session bearer authenticates on the no-SSO path; otherwise the admin-API case stays untested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_cli/src/commands/webui_auth.rs` around lines 76 - 98,
The no-SSO branch in webui_auth.rs now constructs a CompositeAuthenticator with
a SessionAuthenticator, but the test only checks public_mount; update the
relevant webui auth test to also exercise the minted session-bearer path. Add an
assertion that a session token created through the no-SSO setup authenticates
successfully via the WebuiAuthenticator, using the same
no-SSO/CompositeAuthenticator flow so the admin-API bearer case is covered.

Source: Path instructions

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 41 changed files in this pull request and generated 5 comments.

Comment on lines +316 to +330
/// `PUT /api/webchat/v2/admin/users/{user_id}/secrets/{handle}`
pub async fn admin_put_user_secret(
State(state): State<WebUiV2State>,
Extension(caller): Extension<WebUiAuthenticatedCaller>,
Path((user_id, handle)): Path<(String, String)>,
Json(body): Json<RebornAdminPutSecretRequest>,
) -> Result<Json<RebornAdminSecretResponse>, WebUiV2HttpError> {
let user_id = parse_admin_user_id(user_id)?;
Ok(Json(
state
.services()
.put_admin_user_secret(caller, user_id, handle, body)
.await?,
))
}
Comment on lines +332 to +345
/// `DELETE /api/webchat/v2/admin/users/{user_id}/secrets/{handle}`
pub async fn admin_delete_user_secret(
State(state): State<WebUiV2State>,
Extension(caller): Extension<WebUiAuthenticatedCaller>,
Path((user_id, handle)): Path<(String, String)>,
) -> Result<Json<RebornAdminSecretDeletedResponse>, WebUiV2HttpError> {
let user_id = parse_admin_user_id(user_id)?;
Ok(Json(
state
.services()
.delete_admin_user_secret(caller, user_id, handle)
.await?,
))
}
Comment on lines +190 to +195
// Every handler delegates straight to the facade, which enforces admin
// authorization (operator token or admin/owner role) and last-admin protection.
// The `{user_id}` path segment is parsed into a `UserId` here so a malformed id
// is a 400 before the facade runs; the `{handle}` segment stays a String and is
// validated deeper (the secret store rejects a bad handle).

Comment on lines +149 to +159
/// Build a signed-token [`SessionStore`] for minting/validating bearers from an
/// operator secret + tenant. The store is stateless and deterministic in the
/// signing key, so an instance built here mints tokens that validate under any
/// other instance sharing the same operator secret + tenant (e.g. the SSO login
/// surface's own store). Used by the admin user-management surface to mint the
/// one-time API bearer on user create, which must be wired before the login
/// surface (and its own store) is composed.
pub fn signed_session_store(
operator_secret: &SecretString,
tenant_id: &TenantId,
) -> std::sync::Arc<dyn SessionStore> {
Comment on lines +357 to +361
/// Compose an env-bearer authenticator with a session authenticator. The
/// env token is tried first (it carries operator capabilities); a token it
/// rejects falls through to the non-operator `session` authenticator.
pub fn new(
session: Arc<dyn WebuiAuthenticator>,
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>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 7, 2026 19:31 Destroyed
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>
Copilot AI review requested due to automatic review settings July 8, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 8, 2026 10:01 Destroyed
Copilot AI review requested due to automatic review settings July 8, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 8, 2026 10:06 Destroyed
Copilot AI review requested due to automatic review settings July 8, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 8, 2026 10:13 Destroyed
Copilot AI review requested due to automatic review settings July 8, 2026 10:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5779 July 8, 2026 10:31 Destroyed
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 85.32% (283640 / 332453 lines)
  floor:    85.3% (tolerance 0.5pp -> effective floor 84.8%)
  denominator: 332453 lines now vs 320188 at floor capture (+12265 lines, +3.83%) — not a material change

⚠️ 3 Reborn crate(s) have 0 int-tier coverage (target: 0) — ironclaw_prompt_envelope, ironclaw_scripts, ironclaw_skill_learning

Reborn integration-tier coverage

Line coverage (Reborn crates): 85.32% — 283640 / 332453 lines

Per-crate breakdown (65 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_prompt_envelope 0% 0 / 88
ironclaw_scripts 0% 0 / 347
ironclaw_skill_learning 0% 0 / 61
ironclaw_wasm_sandbox_core 7.37% 7 / 95
ironclaw_runtime_policy 33.2% 80 / 241
ironclaw_event_projections 43.34% 673 / 1553
ironclaw_run_state 52.36% 222 / 424
ironclaw_authorization 53.54% 461 / 861
ironclaw_triggers 60.32% 1736 / 2878
ironclaw_observability 61.54% 16 / 26
ironclaw_webui_v2 62.69% 2497 / 3983
ironclaw_mcp 63.15% 581 / 920
ironclaw_reborn_cli 64.48% 3995 / 6196
ironclaw_reborn_migration 67.01% 1172 / 1749
ironclaw_memory 67.12% 747 / 1113
ironclaw_dispatcher 67.15% 92 / 137
ironclaw_filesystem 67.44% 3815 / 5657
ironclaw_trust 72.88% 661 / 907
ironclaw_capabilities 74.08% 1658 / 2238
ironclaw_wasm_limiter 74.6% 47 / 63
ironclaw_reborn_event_store 74.61% 958 / 1284
ironclaw_extractors 74.72% 538 / 720
ironclaw_first_party_extensions 77.62% 5410 / 6970
ironclaw_llm 77.88% 19480 / 25013
ironclaw_product_context 78.57% 11 / 14
ironclaw_wasm_product_adapters 80.58% 1510 / 1874
ironclaw_process_sandbox 80.65% 671 / 832
ironclaw_reborn_openai_compat 80.95% 956 / 1181
ironclaw_memory_native 81.86% 3226 / 3941
ironclaw_wasm 82.54% 950 / 1151
ironclaw_secrets 82.7% 2791 / 3375
ironclaw_events 83.47% 1762 / 2111
ironclaw_processes 84.06% 965 / 1148
ironclaw_turns 84.28% 13083 / 15523
ironclaw_host_api 84.8% 3131 / 3692
ironclaw_product_workflow 85.88% 10818 / 12597
ironclaw_projects 85.92% 659 / 767
ironclaw_network 86.12% 670 / 778
ironclaw_auth 86.32% 2727 / 3159
ironclaw_threads 86.33% 4015 / 4651
ironclaw_common 86.59% 1472 / 1700
ironclaw_slack_v2_adapter 86.79% 1806 / 2081
ironclaw_reborn_config 86.98% 1730 / 1989
ironclaw_reborn_identity 87.03% 557 / 640
ironclaw_product_adapters 87.29% 3207 / 3674
ironclaw_skills 87.37% 4337 / 4964
ironclaw_hooks 87.84% 9916 / 11289
ironclaw_product_adapter_registry 87.96% 526 / 598
ironclaw_reborn_traces 88.23% 11707 / 13268
ironclaw_extensions 88.26% 2631 / 2981
ironclaw_host_runtime 88.92% 17477 / 19655
ironclaw_reborn_composition 88.96% 69289 / 77892
ironclaw_conversations 90% 2924 / 3249
ironclaw_approvals 90.51% 1507 / 1665
ironclaw_reborn 91.23% 17562 / 19251
ironclaw_event_streams 91.48% 1009 / 1103
ironclaw_loop_support 92.22% 14231 / 15432
ironclaw_resources 93.05% 4607 / 4951
ironclaw_attachments 93.06% 630 / 677
ironclaw_reborn_webui_ingress 93.16% 2205 / 2367
ironclaw_telegram_v2_adapter 94.01% 2447 / 2603
ironclaw_agent_loop 94.54% 8753 / 9259
ironclaw_safety 94.81% 3669 / 3870
ironclaw_first_party_extension_ports 95% 3094 / 3257
ironclaw_outbound 95.59% 3556 / 3720

This table itself is informational and never gates the PR on its own — not the percentage, not the per-crate holes, not the 0-coverage callout. A separate coverage ratchet (dry-run until enforce=true; see tests/integration/coverage-floor.toml) can fail the build on specific configured floors.

Exemptions (4 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_embeddings v1-only: consumed only by root ironclaw (src/app.rs, src/tools/builtin/memory.rs, src/workspace/mod.rs, src/config/{mod,embeddings}.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_gateway v1-only: consumed only by root ironclaw (src/channels/web/platform/static_files.rs, src/channels/web/handlers/frontend.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_oauth v1-only: consumed only by root ironclaw (src/auth/oauth.rs); no crates/* dependents. Crate's own doc comment confirms v1-only. Covered by "Tests (Legacy)". #5657
crate: ironclaw_tui v1-only: consumed only by root ironclaw (src/main.rs, src/channels/tui.rs); no crates/* dependents. Crate's own doc comment confirms it bridges INTO v1, not Reborn. Covered by "Tests (Legacy)". #5657

@serrrfirat
serrrfirat merged commit 2a2fa3f into main Jul 8, 2026
62 checks passed
@serrrfirat
serrrfirat deleted the feat/reborn-admin-user-api branch July 8, 2026 10:55

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-5779 — 17b03065 Deployed Jul 8, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants