feat(auth): bootstrap collab admin from proxy.is_admin + dev-mode honesty #21

Merged
sameh-farouk merged 1 commit from feat/admin-bootstrap into development 2026-04-26 17:35:49 +00:00
Member

Closes #20.

Summary

Two coupled fixes that make collab's identity contract honest in both auth modes:

  1. Proxy mode — bootstrap admin from hero_proxy.users.is_admin on first user.me, with a first-user-on-empty-DB fallback when proxy is unreachable. Closes the chicken-and-egg deadlock where the deployer can't do anything after a fresh install.

  2. Dev mode — drop any X-Hero-User the proxy injected, so the picker actually appears even when the operator has user_networks rows configured in hero_proxy.proxy.db. Closes the "why doesn't the picker show?" trap.

These are coupled because they are the two halves of one architectural property: hero_proxy injects identity; hero_collab decides whether and how to act on it. Splitting them would leave collab in a half-honest state.

Changes

File Change
crates/hero_collab_server/src/handlers/user.rs + query_proxy_is_admin(identifier) -> Option<bool> (bounded 3s proxy lookup, mirrors federation::fetch_federated_users). + grant_system_admin(db, user_id) (idempotent INSERT-OR-IGNORE into groups / group_members / group_rights). Bootstrap branch in user.me first-INSERT path that grants admin per the rules below.
crates/hero_collab_server/src/main.rs 4-line shim at the top of handle_rpc that maps hero_user to None when state.auth_mode == AuthMode::Dev, with a tracing::debug line so operators can see why the header was dropped.

Net: +170 lines, 0 removals. No schema change. Uses already-imported hero_proxy_sdk.

Bootstrap admin rules (priority order)

  1. Proxy reports is_admin = 1 → grant. The proxy is the directory of "who deployed this stack"; if they're admin there, they're admin here.
  2. Proxy unreachable AND collab DB has no other users → grant. First-user-on-empty-DB pattern, mirrors hero_osis's "first user_create gets Owner role." Bootstrap convenience for fresh installs where proxy might also be coming up.
  3. Otherwise (proxy says is_admin=0, OR proxy unreachable + DB populated) → no grant. Joining users in a populated deployment land without admin and wait for an invite — the operator-deployed model collab is designed for.

The bootstrap branch only runs on the first-INSERT path of user.me (when no row exists for the identifier yet). Subsequent calls hit the cache hot-path and skip the bootstrap entirely.

Dev-mode shim semantics

In dev mode, collab is the source of truth for identity. The 4-line shim explicitly drops upstream X-Hero-User so the picker contract is honest:

let hero_user = match state.auth_mode {
    AuthMode::Dev => {
        if let Some(u) = hero_user {
            tracing::debug!(
                "auth_mode=dev — ignoring X-Hero-User='{}' (set --auth-mode=proxy to honor it)",
                u
            );
        }
        None
    }
    AuthMode::Proxy => hero_user,
};

Side effect: bootstrap admin doesn't fire in dev mode (because the shim makes effective_hero_user = None → no first-INSERT branch). That's correct — dev mode users come from --seed-dev-users or the picker, not from federation.

Behaviour matrix

Scenario Today After this PR
Proxy mode, fresh deployment, deployer's first request PermissionDenied auto-admin via proxy.is_admin=1
Proxy mode, proxy unreachable, fresh DB PermissionDenied first user becomes admin (osis-style fallback)
Proxy mode, proxy unreachable, populated DB PermissionDenied unchanged — no auto-grant
Proxy mode, joining user (proxy.is_admin=0) PermissionDenied → user provisioned without admin unchanged — waits for invite
Dev mode, no proxy in front picker shows unchanged
Dev mode, proxy injecting via user_networks picker silently bypassed picker shows; proxy header explicitly dropped + logged
Dev mode + --seed-dev-users (4 picker users) picker shows users from dev_seed unchanged

Test plan

  • cargo build --release -p hero_collab_server clean
  • Pre-fix: workspace.create from auto-provisioned sameh fails with PermissionDenied
  • Post-fix: workspace.create succeeds — sameh has right=admin row in groups/group_rights after first user.me, granted automatically because proxy.users.is_admin = 1 for sameh
  • First-user-on-empty-DB fallback: stop hero_proxy, wipe collab DB, restart collab in proxy mode, hit user.me — first user becomes admin even with proxy down
  • Dev mode + user_networks configured for loopback: picker now appears (was silently bypassed before)
  • Dev mode + no user_networks: picker appears (unchanged)
  • Proxy mode + user_networks configured: identity injection still works (unchanged)
  • Reviewer to validate: 3s proxy timeout is reasonable; longer = noticeable hang on first user login per new user, shorter = risk of false negatives during proxy slow-start

Risk notes

3s proxy lookup latency on first user.me per new user. Acceptable because it only happens once per user (cached afterward); same pattern collab already uses for collab.users.available federation. Could be tightened to 1-2s if it bites.

Bootstrap admin race on empty DB. If proxy is unreachable AND DB is wiped AND multiple proxy-authed users hit user.me simultaneously, only the first one wins (others hit the populated-DB branch and don't auto-grant). In practice the operator gets there first because they're the one running the proxy. Acceptable trade-off for the bootstrap convenience.

No ability to revoke proxy.is_admin federation later. If the proxy demotes a user from admin, collab's grant persists. Out of scope for this PR — would need a separate sync/reconcile path. File as follow-up if it becomes a real concern.

Closes #20. ## Summary Two coupled fixes that make collab's identity contract honest in both auth modes: 1. **Proxy mode** — bootstrap admin from `hero_proxy.users.is_admin` on first `user.me`, with a first-user-on-empty-DB fallback when proxy is unreachable. Closes the chicken-and-egg deadlock where the deployer can't do anything after a fresh install. 2. **Dev mode** — drop any `X-Hero-User` the proxy injected, so the picker actually appears even when the operator has `user_networks` rows configured in `hero_proxy.proxy.db`. Closes the "why doesn't the picker show?" trap. These are coupled because they are the two halves of one architectural property: hero_proxy injects identity; hero_collab decides whether and how to act on it. Splitting them would leave collab in a half-honest state. ## Changes | File | Change | |---|---| | `crates/hero_collab_server/src/handlers/user.rs` | + `query_proxy_is_admin(identifier) -> Option<bool>` (bounded 3s proxy lookup, mirrors `federation::fetch_federated_users`). + `grant_system_admin(db, user_id)` (idempotent INSERT-OR-IGNORE into `groups` / `group_members` / `group_rights`). Bootstrap branch in `user.me` first-INSERT path that grants admin per the rules below. | | `crates/hero_collab_server/src/main.rs` | 4-line shim at the top of `handle_rpc` that maps `hero_user` to `None` when `state.auth_mode == AuthMode::Dev`, with a `tracing::debug` line so operators can see why the header was dropped. | Net: +170 lines, 0 removals. No schema change. Uses already-imported `hero_proxy_sdk`. ## Bootstrap admin rules (priority order) 1. **Proxy reports `is_admin = 1`** → grant. The proxy is the directory of "who deployed this stack"; if they're admin there, they're admin here. 2. **Proxy unreachable AND collab DB has no other users** → grant. First-user-on-empty-DB pattern, mirrors `hero_osis`'s "first user_create gets Owner role." Bootstrap convenience for fresh installs where proxy might also be coming up. 3. **Otherwise** (proxy says is_admin=0, OR proxy unreachable + DB populated) → no grant. Joining users in a populated deployment land without admin and wait for an invite — the operator-deployed model collab is designed for. The bootstrap branch only runs on the first-INSERT path of `user.me` (when no row exists for the identifier yet). Subsequent calls hit the cache hot-path and skip the bootstrap entirely. ## Dev-mode shim semantics In dev mode, collab is the source of truth for identity. The 4-line shim explicitly drops upstream `X-Hero-User` so the picker contract is honest: ```rust let hero_user = match state.auth_mode { AuthMode::Dev => { if let Some(u) = hero_user { tracing::debug!( "auth_mode=dev — ignoring X-Hero-User='{}' (set --auth-mode=proxy to honor it)", u ); } None } AuthMode::Proxy => hero_user, }; ``` Side effect: bootstrap admin doesn't fire in dev mode (because the shim makes `effective_hero_user = None` → no first-INSERT branch). That's correct — dev mode users come from `--seed-dev-users` or the picker, not from federation. ## Behaviour matrix | Scenario | Today | After this PR | |---|---|---| | Proxy mode, fresh deployment, deployer's first request | PermissionDenied | auto-admin via `proxy.is_admin=1` | | Proxy mode, proxy unreachable, fresh DB | PermissionDenied | first user becomes admin (osis-style fallback) | | Proxy mode, proxy unreachable, populated DB | PermissionDenied | unchanged — no auto-grant | | Proxy mode, joining user (proxy.is_admin=0) | PermissionDenied → user provisioned without admin | unchanged — waits for invite | | Dev mode, no proxy in front | picker shows | unchanged | | Dev mode, proxy injecting via `user_networks` | **picker silently bypassed** | picker shows; proxy header explicitly dropped + logged | | Dev mode + `--seed-dev-users` (4 picker users) | picker shows users from dev_seed | unchanged | ## Test plan - [x] `cargo build --release -p hero_collab_server` clean - [x] Pre-fix: `workspace.create` from auto-provisioned sameh fails with PermissionDenied - [x] Post-fix: workspace.create succeeds — sameh has `right=admin` row in `groups`/`group_rights` after first user.me, granted automatically because `proxy.users.is_admin = 1` for sameh - [x] First-user-on-empty-DB fallback: stop hero_proxy, wipe collab DB, restart collab in proxy mode, hit user.me — first user becomes admin even with proxy down - [x] Dev mode + `user_networks` configured for loopback: picker now appears (was silently bypassed before) - [x] Dev mode + no `user_networks`: picker appears (unchanged) - [x] Proxy mode + `user_networks` configured: identity injection still works (unchanged) - [ ] Reviewer to validate: 3s proxy timeout is reasonable; longer = noticeable hang on first user login per new user, shorter = risk of false negatives during proxy slow-start ## Risk notes **3s proxy lookup latency on first user.me per new user.** Acceptable because it only happens once per user (cached afterward); same pattern collab already uses for `collab.users.available` federation. Could be tightened to 1-2s if it bites. **Bootstrap admin race on empty DB.** If proxy is unreachable AND DB is wiped AND multiple proxy-authed users hit user.me simultaneously, only the first one wins (others hit the populated-DB branch and don't auto-grant). In practice the operator gets there first because they're the one running the proxy. Acceptable trade-off for the bootstrap convenience. **No ability to revoke proxy.is_admin federation later.** If the proxy demotes a user from admin, collab's grant persists. Out of scope for this PR — would need a separate sync/reconcile path. File as follow-up if it becomes a real concern.
Two coupled changes that make collab honest about identity in both
auth modes.

## Bootstrap admin (proxy mode)

Auto-provisioned users via `user.me` landed with zero rights — the
deployer (proxy admin) couldn't `workspace.create`, invite anyone,
or do anything until someone manually granted admin via SQL.
Chicken-and-egg on every fresh deployment.

Two-tier bootstrap rule (in priority order):
  1. Query hero_proxy via `users_list` (3s timeout, mirrors
     federation::fetch_federated_users). If proxy reports the user
     `is_admin=1`, mirror that into a collab system `admins` group
     with `right=admin`.
  2. If proxy is unreachable AND collab.users has no other users
     (excluding the row we just inserted), grant admin anyway —
     first-user-on-empty-DB pattern, mirrors hero_osis's
     `first user_create gets Owner role`.

Otherwise: no grant. Joining users in a populated deployment land
without admin and wait for an invite, which is the operator-deployed
model collab is designed for.

Two new helpers in `handlers/user.rs`:
  * `query_proxy_is_admin(identifier) -> Option<bool>` — bounded
    proxy lookup. Returns `Some(true|false)` if proxy reachable + user
    found; `None` if proxy unreachable, times out, or user not in
    proxy.users. Caller falls back to local heuristic on `None`.
  * `grant_system_admin(db, user_id)` — idempotent INSERT-OR-IGNORE
    into `groups`, `group_members`, `group_rights`. Safe to call
    multiple times; bootstrap re-entry produces no duplicates.

The bootstrap branch runs only on the first-INSERT path of `user.me`
(when no row exists for the identifier yet). Subsequent `user.me`
calls hit the cache hot-path and skip the bootstrap entirely.

## Dev-mode honesty (dev mode)

The natural counterpart: collab is now the source of truth for
identity in `--auth-mode=dev`. Drop any X-Hero-User the proxy
injected (typically via `user_networks` IP-auto-login) so the picker
actually appears.

Without this guard, dev mode silently bypassed the picker whenever
the operator had loopback rows in `hero_proxy.user_networks` —
confusing because user_networks is a hero_proxy concept the collab
operator may not even know is active. Since `--auth-mode=dev` is an
explicit operator statement of "I want pickers / multi-user testing
here," we honor that statement by ignoring the upstream identity
entirely.

Implementation: 4-line shim at the top of `handle_rpc` that maps
`hero_user` to `None` when `state.auth_mode == AuthMode::Dev`,
emitting a tracing::debug. Proxy mode is unchanged.

## Why coupled in one PR

These are the two halves of a single architectural property:
hero_proxy injects identity for IPs it auto-trusts; hero_collab
decides whether to act on that. In proxy mode it acts (with the new
bootstrap admin). In dev mode it explicitly doesn't. Splitting them
would leave collab in a half-honest state for one merge.

## No schema change. Uses already-imported `hero_proxy_sdk`.
sameh-farouk merged commit 138e1c5be9 into development 2026-04-26 17:35:49 +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_collab!21
No description provided.