[arch] auth/user mgmt make sure is defined well & understood #191

Open
opened 2026-04-26 18:41:07 +00:00 by sameh-farouk · 6 comments
Member

deliverable

spec in this hero_skill repo under architecture, make sure all understand well how this works
and its in line with architecture

What's the question

hero_proxy is the edge — terminates TLS, runs auth (bearer / OAuth / secp256k1 signature / IP-auto-login), and injects three identity headers on every forwarded request:

Five backends I audited handle these five different ways. There's no stack convention, no portable client across services, and one big load-bearing question about hero_osis's identity domain that needs an answer before any convention can land.

State today (5 backends, 5 different answers)

Service reads X-Hero-User? X-Hero-Context? X-Hero-Claims? local users table? external_id col? dev bypass?
hero_collab required, fail-closed logged only logged only yes — auto-provisioned via user.me yes --auth-mode=dev
hero_voice no logged only logged only no n/a none
hero_slides no no no no n/a none
hero_whiteboard no no no yes — but no external_id, decoupled from proxy no none
hero_agent no — uses local JWT yes (forwarded to MCP downstreams) yes (forwarded to MCP downstreams) no n/a anonymous fallback

Implications:

  • No portable client. A client speaking proxy's X-Hero-User has full identity in collab, no reachable identity in whiteboard (no external_id to map onto), is effectively anonymous in voice/slides, and is the wrong shape for agent (which expects a JWT).
  • X-Hero-Claims is wire-defined but operationally inert. Collab logs it; agent forwards it to MCP downstreams; nobody makes a permission decision on it. The proxy spends BFS work computing it on every request.
  • X-Hero-Context is sourced and injected but no service scopes data by it. The denormalized proxy.users.context source decision (5f7bb04) settled the proxy side; the downstream side is unanswered.

Concrete reference impl (one possible answer for the collab side)

Recently merged in hero_collab as one option for "how a downstream service consumes proxy-injected identity":

  • hero_collab PR #21 feat(auth): bootstrap collab admin from proxy.is_admin + dev-mode honesty — on first user.me, queries hero_proxy_sdk::users_list (3s timeout) and grants collab admin if proxy reports is_admin=1. Falls back to "first-user-on-empty-DB grants admin" when proxy is unreachable (mirrors hero_osis's first user_create gets Owner role). Plus a 4-line shim that drops upstream X-Hero-User when --auth-mode=dev (collab declares itself the source of truth for identity in dev mode).

This is collab's local choice, not a stack convention. Nothing about #21 obliges voice / slides / whiteboard / agent to do anything similar.

The load-bearing question — what is hero_osis's identity domain for?

hero_osis's identity schema defines User, Group, Session, Device, Profile, Contact, SshKey, plus a full ed25519 challenge-response AuthService. None of it is in the request path of any web-facing app:

  • hero_proxy does its own auth and stores its own users table; after 5f7bb04 it explicitly does NOT live-sync identity from osis (the context_sync.rs module that briefly existed was reverted within hours of being added).
  • hero_collab reads X-Hero-User from proxy; never queries osis for identity.
  • hero_agent imports hero_osis_sdk and connects at startup, but the connection is not in the auth path.
  • voice / slides / whiteboard don't link to osis at all.

Meanwhile osis's communication domain is being actively developed (e.g. list_messages added e0e02dd on Apr 19). So the codebase is alive — identity just isn't in the operational flow.

Three plausible explanations the team needs to pick one of:

  1. Different audience. osis identity is for native / mobile / key-based clients using ed25519, not browser clients behind hero_proxy. Both auth systems coexist by design. If so: document it — "for browser clients, the user store is proxy.users; for key-based clients, it's osis.User."
  2. Future state. osis is intended to become the source of truth, proxy.users is transitional. 5f7bb04's revert was tactical (that specific sync was wrong), not strategic (osis irrelevant). If so: path and timeline?
  3. Operationally orphaned. osis identity is alive in the data model but dormant in the request path. If so: stop calling it the identity tier.

Without a chosen answer, every new app maintainer faces the same fork ("wire to proxy or osis?") with no canonical answer; the default ends up being "wire to neither" (voice, slides) or "wire to proxy and ignore the rest" (collab).

Questions

  1. What is hero_osis's identity domain for? (one of the three above, or a fourth I'm missing)
  2. Should there be a stack convention for X-Hero-User handling? Reference impl in collab PR #21 — do other services adopt this pattern, or is per-service freedom intentional?
  3. X-Hero-Claims: enforce it or drop it? Currently nobody decides on it; the proxy spends compute producing it on every request.

Question 1 precedes the others — the answer changes whether external_id should map to proxy.users.id or osis.User.sid, and whether display_name should be sourced from proxy or osis. If there's appetite for a one-page "identity contract for hero apps" that codifies the answers, happy to draft it.

## deliverable spec in this hero_skill repo under architecture, make sure all understand well how this works and its in line with architecture ## What's the question [`hero_proxy`](https://forge.ourworld.tf/lhumina_code/hero_proxy) is the edge — terminates TLS, runs auth (bearer / OAuth / secp256k1 signature / IP-auto-login), and injects three identity headers on every forwarded request: - `X-Hero-User: <username>` — the authenticated principal - `X-Hero-Context: <integer>` — context id, sourced from `proxy.users.context` since [commit `5f7bb04` (proxy #23, "source X-Hero-Context from authenticated user, not route")](https://forge.ourworld.tf/lhumina_code/hero_proxy/commit/5f7bb04) - `X-Hero-Claims: <comma-separated>` — flattened RBAC claims via group → role → claim BFS **Five backends I audited handle these five different ways. There's no stack convention, no portable client across services, and one big load-bearing question about hero_osis's identity domain that needs an answer before any convention can land.** ## State today (5 backends, 5 different answers) | Service | reads `X-Hero-User`? | `X-Hero-Context`? | `X-Hero-Claims`? | local users table? | `external_id` col? | dev bypass? | |---|---|---|---|---|---|---| | [hero_collab](https://forge.ourworld.tf/lhumina_code/hero_collab) | required, fail-closed | logged only | logged only | yes — auto-provisioned via `user.me` | yes | `--auth-mode=dev` | | [hero_voice](https://forge.ourworld.tf/lhumina_code/hero_voice) | no | logged only | logged only | no | n/a | none | | [hero_slides](https://forge.ourworld.tf/lhumina_code/hero_slides) | no | no | no | no | n/a | none | | [hero_whiteboard](https://forge.ourworld.tf/lhumina_code/hero_whiteboard) | no | no | no | yes — but **no `external_id`**, decoupled from proxy | no | none | | [hero_agent](https://forge.ourworld.tf/lhumina_code/hero_agent) | no — uses local JWT | yes (forwarded to MCP downstreams) | yes (forwarded to MCP downstreams) | no | n/a | anonymous fallback | Implications: - **No portable client.** A client speaking proxy's `X-Hero-User` has full identity in collab, no reachable identity in whiteboard (no `external_id` to map onto), is effectively anonymous in voice/slides, and is the wrong shape for agent (which expects a JWT). - **`X-Hero-Claims` is wire-defined but operationally inert.** Collab logs it; agent forwards it to MCP downstreams; nobody makes a permission decision on it. The proxy spends BFS work computing it on every request. - **`X-Hero-Context` is sourced and injected** but no service scopes data by it. The denormalized `proxy.users.context` source decision (5f7bb04) settled the proxy side; the downstream side is unanswered. ## Concrete reference impl (one possible answer for the collab side) Recently merged in hero_collab as one option for "how a downstream service consumes proxy-injected identity": - [hero_collab PR #21 `feat(auth): bootstrap collab admin from proxy.is_admin + dev-mode honesty`](https://forge.ourworld.tf/lhumina_code/hero_collab/pulls/21) — on first `user.me`, queries `hero_proxy_sdk::users_list` (3s timeout) and grants collab `admin` if proxy reports `is_admin=1`. Falls back to "first-user-on-empty-DB grants admin" when proxy is unreachable (mirrors hero_osis's `first user_create gets Owner role`). Plus a 4-line shim that drops upstream `X-Hero-User` when `--auth-mode=dev` (collab declares itself the source of truth for identity in dev mode). This is **collab's local choice**, not a stack convention. Nothing about #21 obliges voice / slides / whiteboard / agent to do anything similar. ## The load-bearing question — what is hero_osis's identity domain for? [`hero_osis`'s `identity` schema](https://forge.ourworld.tf/lhumina_code/hero_osis/src/branch/development/crates/hero_osis/schemas/identity) defines `User`, `Group`, `Session`, `Device`, `Profile`, `Contact`, `SshKey`, plus a full ed25519 challenge-response `AuthService`. **None of it is in the request path of any web-facing app**: - hero_proxy does its own auth and stores its own `users` table; after [`5f7bb04`](https://forge.ourworld.tf/lhumina_code/hero_proxy/commit/5f7bb04) it explicitly does NOT live-sync identity from osis (the `context_sync.rs` module that briefly existed was reverted within hours of being added). - hero_collab reads `X-Hero-User` from proxy; never queries osis for identity. - hero_agent imports `hero_osis_sdk` and connects at startup, but the connection is not in the auth path. - voice / slides / whiteboard don't link to osis at all. Meanwhile osis's [`communication` domain](https://forge.ourworld.tf/lhumina_code/hero_osis/src/branch/development/crates/hero_osis/schemas/communication) is being actively developed (e.g. `list_messages` added [`e0e02dd`](https://forge.ourworld.tf/lhumina_code/hero_osis/commit/e0e02dd) on Apr 19). So the codebase is alive — identity just isn't in the operational flow. Three plausible explanations the team needs to pick one of: 1. **Different audience.** osis identity is for native / mobile / key-based clients using ed25519, not browser clients behind hero_proxy. Both auth systems coexist by design. *If so: document it — "for browser clients, the user store is `proxy.users`; for key-based clients, it's `osis.User`."* 2. **Future state.** osis is intended to become the source of truth, `proxy.users` is transitional. `5f7bb04`'s revert was tactical (that specific sync was wrong), not strategic (osis irrelevant). *If so: path and timeline?* 3. **Operationally orphaned.** osis identity is alive in the data model but dormant in the request path. *If so: stop calling it the identity tier.* Without a chosen answer, every new app maintainer faces the same fork ("wire to proxy or osis?") with no canonical answer; the default ends up being "wire to neither" (voice, slides) or "wire to proxy and ignore the rest" (collab). ## Questions 1. **What is `hero_osis`'s identity domain for?** (one of the three above, or a fourth I'm missing) 2. **Should there be a stack convention for `X-Hero-User` handling?** Reference impl in [collab PR #21](https://forge.ourworld.tf/lhumina_code/hero_collab/pulls/21) — do other services adopt this pattern, or is per-service freedom intentional? 3. **`X-Hero-Claims`: enforce it or drop it?** Currently nobody decides on it; the proxy spends compute producing it on every request. Question 1 precedes the others — the answer changes whether `external_id` should map to `proxy.users.id` or `osis.User.sid`, and whether `display_name` should be sourced from proxy or osis. If there's appetite for a one-page "identity contract for hero apps" that codifies the answers, happy to draft it.
Owner

I agree it is very important to settle this.

@despiegk @timur have been working on this refactoring I think they should share their POV.

Thanks @sameh-farouk

I agree it is very important to settle this. @despiegk @timur have been working on this refactoring I think they should share their POV. Thanks @sameh-farouk
Owner

Thanks @sameh-farouk — this audit is great and it lines up almost exactly with what Kristof outlined in today's meeting. Sharing what we now know, and where we still need an answer.

What's decided

  • Proxy is the identity tier for web/browser flows. proxy.users stays. Apps consume X-Hero-User / X-Hero-Context / X-Hero-Claims from the proxy.
  • Login: forge OAuth first (forge is already an OAuth provider, auto-create the user on login), Google second. No passwords — too messy.
  • Claims are the model — not groups/roles inside the apps. Roles are bundles of claim strings (engineers.tickets.read, engineers.projects.one, etc.). Apps match by prefix (engineers.*). Apps shouldn't carry their own notion of groups/users except where they genuinely have to (e.g. collab, user-to-user).
  • A default automatic claim like users.<username> is set by the proxy so a user can always edit their own profile without an explicit role.
  • Service + context scoping happens at the proxy/router, not in the backends. Backends just read the injected headers and decide on claims.
  • X-Hero-Claims stays and gets enforced — that's the whole authorization model going forward, not dead weight.
  • All services should integrate behind the proxy — whiteboard, livekit, agent, etc.

What's still open

Kristof asked @lee to take ownership of the proxy track: read the current hero_proxy, identify what's missing, add the OAuth flows, and then help every service integrate. The stack-wide identity convention you're asking for is essentially the deliverable of that work.

Two things specifically would benefit from Lee's eyes once he's read the code:

  1. Your question 1 — what is hero_osis's identity domain for? (web flows use proxy.users, but the role of the osis identity schema — native/key-based clients, future state, or orphaned — wasn't settled in the meeting.) @lee, after you've read through, please weigh in here so we can lock the answer.
  2. Whether the collab-style external_id mapping in PR #21 is the canonical pattern for every service, or if there's a cleaner shape now that proxy is the source of truth.

Let's keep this issue open — it's the right place to converge once Lee has a concrete proposal.

Thanks @sameh-farouk — this audit is great and it lines up almost exactly with what Kristof outlined in today's meeting. Sharing what we now know, and where we still need an answer. ## What's decided - **Proxy is the identity tier for web/browser flows.** `proxy.users` stays. Apps consume `X-Hero-User` / `X-Hero-Context` / `X-Hero-Claims` from the proxy. - **Login: forge OAuth first** (forge is already an OAuth provider, auto-create the user on login), **Google second**. No passwords — too messy. - **Claims are the model — not groups/roles inside the apps.** Roles are bundles of claim strings (`engineers.tickets.read`, `engineers.projects.one`, etc.). Apps match by prefix (`engineers.*`). Apps shouldn't carry their own notion of groups/users except where they genuinely have to (e.g. collab, user-to-user). - **A default automatic claim** like `users.<username>` is set by the proxy so a user can always edit their own profile without an explicit role. - **Service + context scoping happens at the proxy/router**, not in the backends. Backends just read the injected headers and decide on claims. - **`X-Hero-Claims` stays and gets enforced** — that's the whole authorization model going forward, not dead weight. - **All services should integrate behind the proxy** — whiteboard, livekit, agent, etc. ## What's still open Kristof asked **@lee** to take ownership of the proxy track: read the current `hero_proxy`, identify what's missing, add the OAuth flows, and then help every service integrate. The stack-wide identity convention you're asking for is essentially the deliverable of that work. Two things specifically would benefit from Lee's eyes once he's read the code: 1. **Your question 1 — what is `hero_osis`'s identity domain for?** (web flows use `proxy.users`, but the role of the osis identity schema — native/key-based clients, future state, or orphaned — wasn't settled in the meeting.) @lee, after you've read through, please weigh in here so we can lock the answer. 2. **Whether the collab-style `external_id` mapping in [PR #21](https://forge.ourworld.tf/lhumina_code/hero_collab/pulls/21) is the canonical pattern** for every service, or if there's a cleaner shape now that proxy is the source of truth. Let's keep this issue open — it's the right place to converge once Lee has a concrete proposal.
Member

Alright, so from what I understood from my meeting with Kristof, the proxy is the only authentication provider. The proxy inserts claims before forwarding the request. Backend services must not have a separate login/identity system.

With regards to this, I believe hero_osis should remove (deprecated) identity/authentication fields.

All access/authentication is based on claims injected by the proxy. Since backend services might also care about users/groups, it would be best to create and document a list of reserved claims. It seems this is already done with the admin claim. Others, to start, should be user.XXX where XXX is then the username, and group.XXX with XXX being the group name (can be set multiple times). Also, claims will be restricted to ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}(\.[a-zA-Z0-9][a-zA-Z0-9_-]{0,62})*$

Alright, so from what I understood from my meeting with Kristof, the proxy is the only authentication provider. The proxy inserts claims before forwarding the request. Backend services must not have a separate login/identity system. With regards to this, I believe hero_osis should remove (deprecated) identity/authentication fields. All access/authentication is based on `claims` injected by the proxy. Since backend services might also care about users/groups, it would be best to create and document a list of reserved claims. It seems this is already done with the `admin` claim. Others, to start, should be `user.XXX` where `XXX` is then the username, and `group.XXX` with XXX being the group name (can be set multiple times). Also, claims will be restricted to `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}(\.[a-zA-Z0-9][a-zA-Z0-9_-]{0,62})*$`
Owner

see #191

give clear instructions

see https://forge.ourworld.tf/lhumina_code/home/issues/191 give clear instructions
despiegk changed title from Convention question: how should hero_* services consume proxy-injected identity headers? + what is hero_osis's identity domain for? to story: auth/user mgmt make sure is defined well & understood 2026-05-05 03:48:16 +00:00
despiegk added this to the ACTIVE project 2026-05-05 03:48:19 +00:00
despiegk added the due date 2026-05-05 2026-05-05 03:48:28 +00:00
Member

Format: dot-separated ASCII, ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}(.[a-zA-Z0-9][a-zA-Z0-9_-]{0,62})*$, ≤ 255 chars, ≤ 8 segments, no wildcards (those go on the rule side). Default shape .. — e.g.
dns.zone.write. Validated by roles.add_claim.

Reserved prefixes (only proxy may emit):

  • admin — legacy admin claim, deprecated (now groups.admin)
  • users.* — identity claim, auto-emitted as users.
  • groups.* — membership claim, auto-emitted as groups. for every group the user is in (transitively, no opt-in)

Admin transition: the proxy seeds a reserved admin group; membership is auto-synced with users.is_admin (set/unset on add/update; backfilled at startup; manual desync rejected). During the deprecation window both admin and
groups.admin are emitted for is_admin=true users. New rules should match groups.admin; the legacy admin claim will be removed once backends have migrated.

Format: dot-separated ASCII, ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}(\.[a-zA-Z0-9][a-zA-Z0-9_-]{0,62})*$, ≤ 255 chars, ≤ 8 segments, no wildcards (those go on the rule side). Default shape <domain>.<resource>.<action> — e.g. dns.zone.write. Validated by roles.add_claim. Reserved prefixes (only proxy may emit): - admin — legacy admin claim, deprecated (now groups.admin) - users.* — identity claim, auto-emitted as users.<username> - groups.* — membership claim, auto-emitted as groups.<name> for every group the user is in (transitively, no opt-in) Admin transition: the proxy seeds a reserved admin group; membership is auto-synced with users.is_admin (set/unset on add/update; backfilled at startup; manual desync rejected). During the deprecation window both admin and groups.admin are emitted for is_admin=true users. New rules should match groups.admin; the legacy admin claim will be removed once backends have migrated.
mik-tf changed title from story: auth/user mgmt make sure is defined well & understood to [arch] auth/user mgmt make sure is defined well & understood 2026-06-14 04:29:52 +00:00
Owner

Keeping this open. The model is now agreed at the architecture level: the proxy is the single sign in edge and injects the identity and claims headers, identity is the Forge account, and authorization is claims based, documented in the shared authorization skill. What remains is the convergence across backends that still handle these headers differently and the hero_osis identity question. A short restatement of what remains would help close it.

Signed-by: mik-tf mik-tf@noreply.invalid

Keeping this open. The model is now agreed at the architecture level: the proxy is the single sign in edge and injects the identity and claims headers, identity is the Forge account, and authorization is claims based, documented in the shared authorization skill. What remains is the convergence across backends that still handle these headers differently and the hero_osis identity question. A short restatement of what remains would help close it. Signed-by: mik-tf <mik-tf@noreply.invalid>
Sign in to join this conversation.
No milestone
No project
No assignees
5 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

2026-05-05

Dependencies

No dependencies set.

Reference
lhumina_code/home#191
No description provided.