Bootstrap admin from proxy.is_admin + dev-mode honesty (one identity contract, both halves) #20

Closed
opened 2026-04-26 16:06:59 +00:00 by sameh-farouk · 0 comments
Member

Two coupled gaps in collab's identity contract

After running collab in --auth-mode=proxy end-to-end (with hero_proxy injecting X-Hero-User via OAuth/IP-auto-login), I hit two issues that are really the two halves of the same architectural property: hero_proxy injects identity headers; hero_collab decides whether and how to act on them. Today collab's behaviour is inconsistent in both auth modes.

Half 1 — Proxy mode: bootstrap deadlock

handlers/user.rs::me auto-provisions a new user row when X-Hero-User is set but no collab user exists yet. The auto-provisioned user lands with zero rights — no group memberships, no group_rights row. So:

  • The deployer (proxy admin) opens the chat for the first time
  • user.me succeeds, creates a row for them
  • Every subsequent RPC fails with caller 'N' lacks permission for 'X.create'
  • They can't create workspaces, invite anyone, or do anything until someone manually grants admin via SQL

Chicken-and-egg on every fresh deployment. Today's escape hatch is direct SQL against ~/.hero/var/data/hero_collab/collab.db — not acceptable as the standard install flow.

Repro

# Fresh proxy + collab in proxy mode, sameh@example.com is proxy.users.is_admin=1
curl -H 'Content-Type: application/json' \
  http://127.0.0.1:9997/hero_collab/rpc/rpc \
  -d '{"jsonrpc":"2.0","id":1,"method":"workspace.create","params":{"name":"Acme"}}'
# → -32002 PermissionDenied: caller 'N' lacks permission for 'workspace.create'

Half 2 — Dev mode: silent picker bypass

main.rs::handle_rpc processes any X-Hero-User header it receives, regardless of --auth-mode. When the operator runs --auth-mode=dev AND has user_networks rows in hero_proxy.proxy.db matching their loopback (a separate hero_proxy concept they may not even know is active), the chain becomes:

  • Browser → proxy on :9997
  • Proxy find_user_for_ip(127.0.0.1) → matches user_networks → injects X-Hero-User: sameh@example.com
  • Collab in dev mode: receives header → resolves caller_id → chat-app.js::init() sees authenticated:truepicker silently doesn't appear

This is confusing because:

  • --auth-mode=dev was explicitly set to "I want pickers / multi-user testing"
  • user_networks is a hero_proxy concept; the collab operator may not realise it's overriding their dev-mode intent
  • There's no log telling them why the picker isn't showing

The principle is: in dev mode, collab should be the source of truth for identity — period. Anything the proxy injects should be ignored.

Why one issue / one PR

These are the two halves of a single architectural property. Splitting them would leave collab in a half-honest state: either "proxy mode works but dev mode is silently broken" or "dev mode is honest but proxy mode has the bootstrap deadlock." Either intermediate state is more confusing than landing them together.

Fix shape

Half 1 (bootstrap admin in proxy mode)

In handlers/user.rs's first-INSERT path of user.me:

  1. Query hero_proxy_sdk::users_list() for the identifier (3s timeout, mirrors federation::fetch_federated_users).
  2. If the proxy reports is_admin = 1 for this user → grant collab system admin.
  3. If the proxy is unreachable / times out / doesn't know this user → fall back to: is the collab DB empty (no other users)? If yes, grant admin. Else skip.
  4. Otherwise (proxy says is_admin=0) → no grant.

The fallback mirrors hero_osis's first user_create gets Owner role pattern. The grant is idempotent (INSERT OR IGNORE into groups, group_members, group_rights).

Half 2 (dev-mode honesty)

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 so operators can see in logs that the header was dropped.

Combined behaviour matrix

Scenario Today After fix
Proxy mode, fresh deployment, deployer's first request every RPC fails with PermissionDenied; SQL hack required deployer auto-becomes admin on first user.me (via proxy.is_admin)
Proxy mode, proxy unreachable, fresh DB PermissionDenied (proxy lookup returns None, no rights granted) first user becomes admin (osis-style fallback)
Proxy mode, proxy unreachable, populated DB PermissionDenied unchanged — no auto-grant; explicit invite required
Dev mode, no proxy in front picker shows unchanged (picker shows)
Dev mode, proxy injecting via user_networks picker silently bypassed, identity = user_networks user picker shows; proxy header explicitly dropped (with debug log)

PR

Fix in feat/admin-bootstrap. ~170 LOC across handlers/user.rs + main.rs. No schema change. Uses already-imported hero_proxy_sdk.

## Two coupled gaps in collab's identity contract After running collab in `--auth-mode=proxy` end-to-end (with `hero_proxy` injecting `X-Hero-User` via OAuth/IP-auto-login), I hit two issues that are really the two halves of the same architectural property: **hero_proxy injects identity headers; hero_collab decides whether and how to act on them.** Today collab's behaviour is inconsistent in both auth modes. ## Half 1 — Proxy mode: bootstrap deadlock `handlers/user.rs::me` auto-provisions a new user row when `X-Hero-User` is set but no collab user exists yet. The auto-provisioned user lands with **zero rights** — no group memberships, no group_rights row. So: - The deployer (proxy admin) opens the chat for the first time - `user.me` succeeds, creates a row for them - Every subsequent RPC fails with `caller 'N' lacks permission for 'X.create'` - They can't create workspaces, invite anyone, or do anything until someone manually grants admin via SQL Chicken-and-egg on every fresh deployment. Today's escape hatch is direct SQL against `~/.hero/var/data/hero_collab/collab.db` — not acceptable as the standard install flow. ### Repro ```bash # Fresh proxy + collab in proxy mode, sameh@example.com is proxy.users.is_admin=1 curl -H 'Content-Type: application/json' \ http://127.0.0.1:9997/hero_collab/rpc/rpc \ -d '{"jsonrpc":"2.0","id":1,"method":"workspace.create","params":{"name":"Acme"}}' # → -32002 PermissionDenied: caller 'N' lacks permission for 'workspace.create' ``` ## Half 2 — Dev mode: silent picker bypass `main.rs::handle_rpc` processes any `X-Hero-User` header it receives, regardless of `--auth-mode`. When the operator runs `--auth-mode=dev` AND has `user_networks` rows in `hero_proxy.proxy.db` matching their loopback (a separate hero_proxy concept they may not even know is active), the chain becomes: - Browser → proxy on `:9997` - Proxy `find_user_for_ip(127.0.0.1)` → matches `user_networks` → injects `X-Hero-User: sameh@example.com` - Collab in dev mode: receives header → resolves caller_id → `chat-app.js::init()` sees `authenticated:true` → **picker silently doesn't appear** This is confusing because: - `--auth-mode=dev` was explicitly set to "I want pickers / multi-user testing" - `user_networks` is a hero_proxy concept; the collab operator may not realise it's overriding their dev-mode intent - There's no log telling them why the picker isn't showing The principle is: in dev mode, collab should be the source of truth for identity — period. Anything the proxy injects should be ignored. ## Why one issue / one PR These are the two halves of a single architectural property. Splitting them would leave collab in a half-honest state: either "proxy mode works but dev mode is silently broken" or "dev mode is honest but proxy mode has the bootstrap deadlock." Either intermediate state is more confusing than landing them together. ## Fix shape ### Half 1 (bootstrap admin in proxy mode) In `handlers/user.rs`'s first-INSERT path of `user.me`: 1. Query `hero_proxy_sdk::users_list()` for the identifier (3s timeout, mirrors `federation::fetch_federated_users`). 2. If the proxy reports `is_admin = 1` for this user → grant collab system admin. 3. If the proxy is unreachable / times out / doesn't know this user → fall back to: is the collab DB empty (no other users)? If yes, grant admin. Else skip. 4. Otherwise (proxy says is_admin=0) → no grant. The fallback mirrors hero_osis's `first user_create gets Owner role` pattern. The grant is idempotent (`INSERT OR IGNORE` into `groups`, `group_members`, `group_rights`). ### Half 2 (dev-mode honesty) 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` so operators can see in logs that the header was dropped. ### Combined behaviour matrix | Scenario | Today | After fix | |---|---|---| | Proxy mode, fresh deployment, deployer's first request | every RPC fails with PermissionDenied; SQL hack required | deployer auto-becomes admin on first user.me (via proxy.is_admin) | | Proxy mode, proxy unreachable, fresh DB | PermissionDenied (proxy lookup returns None, no rights granted) | first user becomes admin (osis-style fallback) | | Proxy mode, proxy unreachable, populated DB | PermissionDenied | unchanged — no auto-grant; explicit invite required | | Dev mode, no proxy in front | picker shows | unchanged (picker shows) | | Dev mode, proxy injecting via `user_networks` | **picker silently bypassed**, identity = user_networks user | picker shows; proxy header explicitly dropped (with debug log) | ## PR Fix in `feat/admin-bootstrap`. ~170 LOC across `handlers/user.rs` + `main.rs`. No schema change. Uses already-imported `hero_proxy_sdk`.
Sign in to join this conversation.
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#20
No description provided.