feat(proxy): per-method authz on management RPC + admin impersonation #45
No reviewers
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_proxy!45
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "development_lee_proxy_admin_api"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
adminrole carries every proxy.* claim and is attached tothe seeded
admingroup. 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).