feat(proxy): per-method authz on management RPC + admin impersonation #45

Merged
lee merged 1 commit from development_lee_proxy_admin_api into development 2026-05-05 14:15:12 +00:00
Member

Backend admin panels (hero_collab, hero_whiteboard, future apps) need a
browser-direct path to manage users/groups/roles/claims. The proxy already
had the CRUD methods; what was missing was a way to gate them against the
calling admin's claims (instead of a single all-or-nothing bearer token)
and a way for admins to impersonate users for troubleshooting.

Authorization model:

  • Capability claims live in a new reserved namespace proxy.*
    (proxy.users.read/write, proxy.groups.read/write, proxy.roles.read/write,
    proxy.oauth.read/write, proxy.audit.read, proxy.impersonate, proxy.admin).
    Reserved at roles.add_claim — only the proxy's seed path mints them.

  • A seeded admin role carries every proxy.* claim and is attached to
    the seeded admin group. Admin power therefore flows by composition:
    is_admin=true → admin group → admin role → proxy.* claims via the
    existing role-resolution BFS. No special-casing in code.

  • Reserved-name guards on groups.add / groups.update / groups.remove
    and roles.add / roles.update / roles.remove prevent the seeded admin
    group/role from being recreated, renamed, or deleted at runtime.

Caller resolution:

  • New auth::Caller enum (Operator | Session). The TCP /rpc endpoint
    accepts either a bearer token (operator mode, bypasses claim checks)
    or an OAuth session cookie (user mode, claims resolved at middleware
    time and gated per-method). UDS callers are unconditionally Operator
    by virtue of file permissions.

  • handle_rpc takes a Caller, looks up the required claim via
    required_claim_for_method, and rejects with JSON-RPC error -32001 if
    the caller lacks it. Operator bypasses.

  • Audit entries now record the real admin's username via
    caller.audit_actor() instead of always "system". Operator-mode calls
    keep "system" for backwards compatibility.

Impersonation:

  • oauth_sessions gains an impersonating_user_id column (idempotent
    migration). auth.impersonate { user_id } sets it; auth.stop_impersonating
    clears it. Both require proxy.impersonate and a session-authenticated
    caller (Operator can't impersonate — there's no session to overlay).

  • For requests through routes with auth_mode=oauth, the proxy now injects
    the target user's X-Hero-User / X-Hero-Context / X-Hero-Claims when
    the session has an active impersonation overlay, plus
    X-Hero-Impersonator carrying the real admin. allowed_emails on the
    provider is checked against the real admin (impersonation must not let
    an admin into a route their own account is gated out of).

  • The admin's own claims still gate management-API calls during
    impersonation, so they can always auth.stop_impersonating even when
    impersonating a non-admin.

OpenRPC spec adds entries for auth.impersonate / auth.stop_impersonating
so the SDK gets typed bindings.

Tests:

  • 8 new unit tests in lib.rs (authz_tests): required_claim_for_method
    coverage, Caller::has_claim / audit_actor, handle_rpc gates correctly
    for non-admin / admin sessions, audit records the real admin, both
    impersonation arms.

  • 5 new integration tests covering reserved-name guards
    (admin group/role recreation rejected), reserved-claim namespace
    (proxy.* not grantable via roles.add_claim), and admin-claim
    composition (is_admin=true users inherit the full proxy.* set;
    non-admins inherit none of them).

  • Gates: cargo fmt-check, cargo clippy --workspace --all-targets
    -D warnings, cargo test --workspace --lib --bins all green. Full
    integration suite: 29 pass / 6 pre-existing routing failures
    (same as development baseline).

Backend admin panels (hero_collab, hero_whiteboard, future apps) need a browser-direct path to manage users/groups/roles/claims. The proxy already had the CRUD methods; what was missing was a way to gate them against the calling admin's claims (instead of a single all-or-nothing bearer token) and a way for admins to impersonate users for troubleshooting. Authorization model: - Capability claims live in a new reserved namespace `proxy.*` (proxy.users.read/write, proxy.groups.read/write, proxy.roles.read/write, proxy.oauth.read/write, proxy.audit.read, proxy.impersonate, proxy.admin). Reserved at roles.add_claim — only the proxy's seed path mints them. - A seeded `admin` role carries every proxy.* claim and is attached to the seeded `admin` group. Admin power therefore flows by composition: is_admin=true → admin group → admin role → proxy.* claims via the existing role-resolution BFS. No special-casing in code. - Reserved-name guards on groups.add / groups.update / groups.remove and roles.add / roles.update / roles.remove prevent the seeded admin group/role from being recreated, renamed, or deleted at runtime. Caller resolution: - New auth::Caller enum (Operator | Session). The TCP /rpc endpoint accepts either a bearer token (operator mode, bypasses claim checks) or an OAuth session cookie (user mode, claims resolved at middleware time and gated per-method). UDS callers are unconditionally Operator by virtue of file permissions. - handle_rpc takes a Caller, looks up the required claim via required_claim_for_method, and rejects with JSON-RPC error -32001 if the caller lacks it. Operator bypasses. - Audit entries now record the real admin's username via caller.audit_actor() instead of always "system". Operator-mode calls keep "system" for backwards compatibility. Impersonation: - oauth_sessions gains an impersonating_user_id column (idempotent migration). auth.impersonate { user_id } sets it; auth.stop_impersonating clears it. Both require proxy.impersonate and a session-authenticated caller (Operator can't impersonate — there's no session to overlay). - For requests through routes with auth_mode=oauth, the proxy now injects the *target* user's X-Hero-User / X-Hero-Context / X-Hero-Claims when the session has an active impersonation overlay, plus X-Hero-Impersonator carrying the real admin. allowed_emails on the provider is checked against the real admin (impersonation must not let an admin into a route their own account is gated out of). - The admin's own claims still gate management-API calls during impersonation, so they can always auth.stop_impersonating even when impersonating a non-admin. OpenRPC spec adds entries for auth.impersonate / auth.stop_impersonating so the SDK gets typed bindings. Tests: - 8 new unit tests in lib.rs (authz_tests): required_claim_for_method coverage, Caller::has_claim / audit_actor, handle_rpc gates correctly for non-admin / admin sessions, audit records the real admin, both impersonation arms. - 5 new integration tests covering reserved-name guards (admin group/role recreation rejected), reserved-claim namespace (proxy.* not grantable via roles.add_claim), and admin-claim composition (is_admin=true users inherit the full proxy.* set; non-admins inherit none of them). - Gates: cargo fmt-check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace --lib --bins all green. Full integration suite: 29 pass / 6 pre-existing routing failures (same as development baseline).
feat(proxy): per-method authz on management RPC + admin impersonation
All checks were successful
Build & Test / check (push) Successful in 3m9s
Build & Test / check (pull_request) Successful in 3m16s
72e1ada8aa
Backend admin panels (hero_collab, hero_whiteboard, future apps) need a
browser-direct path to manage users/groups/roles/claims. The proxy already
had the CRUD methods; what was missing was a way to gate them against the
calling admin's claims (instead of a single all-or-nothing bearer token)
and a way for admins to impersonate users for troubleshooting.

Authorization model:

  - Capability claims live in a new reserved namespace `proxy.*`
    (proxy.users.read/write, proxy.groups.read/write, proxy.roles.read/write,
    proxy.oauth.read/write, proxy.audit.read, proxy.impersonate, proxy.admin).
    Reserved at roles.add_claim — only the proxy's seed path mints them.

  - A seeded `admin` role carries every proxy.* claim and is attached to
    the seeded `admin` group. Admin power therefore flows by composition:
    is_admin=true → admin group → admin role → proxy.* claims via the
    existing role-resolution BFS. No special-casing in code.

  - Reserved-name guards on groups.add / groups.update / groups.remove
    and roles.add / roles.update / roles.remove prevent the seeded admin
    group/role from being recreated, renamed, or deleted at runtime.

Caller resolution:

  - New auth::Caller enum (Operator | Session). The TCP /rpc endpoint
    accepts either a bearer token (operator mode, bypasses claim checks)
    or an OAuth session cookie (user mode, claims resolved at middleware
    time and gated per-method). UDS callers are unconditionally Operator
    by virtue of file permissions.

  - handle_rpc takes a Caller, looks up the required claim via
    required_claim_for_method, and rejects with JSON-RPC error -32001 if
    the caller lacks it. Operator bypasses.

  - Audit entries now record the real admin's username via
    caller.audit_actor() instead of always "system". Operator-mode calls
    keep "system" for backwards compatibility.

Impersonation:

  - oauth_sessions gains an impersonating_user_id column (idempotent
    migration). auth.impersonate { user_id } sets it; auth.stop_impersonating
    clears it. Both require proxy.impersonate and a session-authenticated
    caller (Operator can't impersonate — there's no session to overlay).

  - For requests through routes with auth_mode=oauth, the proxy now injects
    the *target* user's X-Hero-User / X-Hero-Context / X-Hero-Claims when
    the session has an active impersonation overlay, plus
    X-Hero-Impersonator carrying the real admin. allowed_emails on the
    provider is checked against the real admin (impersonation must not let
    an admin into a route their own account is gated out of).

  - The admin's own claims still gate management-API calls during
    impersonation, so they can always auth.stop_impersonating even when
    impersonating a non-admin.

OpenRPC spec adds entries for auth.impersonate / auth.stop_impersonating
so the SDK gets typed bindings.

Tests:

  - 8 new unit tests in lib.rs (authz_tests): required_claim_for_method
    coverage, Caller::has_claim / audit_actor, handle_rpc gates correctly
    for non-admin / admin sessions, audit records the real admin, both
    impersonation arms.

  - 5 new integration tests covering reserved-name guards
    (admin group/role recreation rejected), reserved-claim namespace
    (proxy.* not grantable via roles.add_claim), and admin-claim
    composition (is_admin=true users inherit the full proxy.* set;
    non-admins inherit none of them).

  - Gates: cargo fmt-check, cargo clippy --workspace --all-targets
    -D warnings, cargo test --workspace --lib --bins all green. Full
    integration suite: 29 pass / 6 pre-existing routing failures
    (same as development baseline).
lee merged commit 2565908582 into development 2026-05-05 14:15:12 +00:00
lee deleted branch development_lee_proxy_admin_api 2026-05-05 14:15:16 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_proxy!45
No description provided.