Skip to content

Role capabilities: Admin / User / ReadOnly

Why this document exists

Any customer whose employees get access to one shared Hivemind instance via SSO faces the same question: which of the three roles does each employee get? Until now nobody could answer it without reading the ~75 require_admin call sites plus the separate orchestration-authorization layer by hand. This is that derivation — from the code, not from intent — done once so that onboarding conversation doesn't have to happen blind.

It also answers three questions nobody had asked: is ReadOnly actually reachable, is it actually more restricted than User, and is anything in this codebase single-user-by-accident in a way that would misbehave with several employees on the same instance at once.

How a role is assigned

See SSO setup (the "Group → role resolution" section) for the full precedence rules. In short: HIVEMIND_GROUP_ADMIN → Admin, HIVEMIND_GROUP_USER → User, HIVEMIND_GROUP_READONLY → ReadOnly (added by HIVE-1025 — before this, Role::ReadOnly existed in the code but no SSO group could ever produce it, so it was untested by any real login). Unmapped identities default to User — see "The unmapped-user default" near the end of this document.

The answer in three lines

  • Admin is the only role that can reach the HTTP admin surface (fleet, audit, bot-perf, updates, the manager/agent-chat admin proxies, server approval, and both proposal/escalation decide routes), and the only role that can start orchestration work at all (propose_dispatch).
  • On the HTTP admin surface, User and ReadOnly are the same bucket — both are simply "not admin," refused identically, because the admin guards reduce to user.role == "admin" and never ask which kind of non-admin the caller is. Away from that surface they are not the same. ReadOnly is refused explicitly in two more places: content writes (memories POST/PATCH/DELETE, and the knowledge/vault ingest, vault_ingest, from_url, events_ingest and delete_doc routes each refuse a read-only caller with 403), and a narrower slice of the orchestration WS layer (a User can nudge/report/handoff/ask-commander and raise an escalation into the operator's queue; a ReadOnly cannot).
  • Nobody — including Admin — can restart_container (or any other VerbClass::OperatorOk verb) through the propose/confirm surface. That tier requires an out-of-band operator grant no login carries. This is not an omission; it is refused by two independent layers on purpose.

Capability table

Surface Admin User ReadOnly Gate
Plain reads (health, list agents/proposals/knowledge search, etc.) yes yes yes require_api_key only — any authenticated principal, no role check
Propose dispatch (start orchestration work) yes no no persisted role == "admin" check, independent of the require_admin warn-only flag
Confirm/decide: orchestration escalations yes no no require_admin_hard — no env opt-out, never-self-approve enforced atomically in SQL
Confirm/decide: MCP tool approvals yes no no require_api_key + require_admin (soft — env opt-out exists; see gap below)
Write: fleet / bot-perf / updates-apply / manager-admin proxy / agent-chat admin (and /api/v1/agent-chat profile create/update) / server-approve yes no no require_admin (soft, warn-only if HIVEMIND_ENFORCE_ADMIN_ROLE=false) or require_admin_hard (server-approve)
Administer: estate-fleet, audit yes no no require_admin_hard / require_admin
Decide: request a change to the orchestration hierarchy (the Hierarchy page, /api/v1/admin/lane-topology) yes, from an interactive session only no no in-module operator-decision gate: a local login or SSO session (an API key or service token is refused, whatever its role), then CSRF, then require_admin_hard; every refusal and change is audited. Appointing the Commander and creating a lane are not offered: both happen at the workstation.
Content writes: create/update/delete a memory; ingest or delete a knowledge or vault document yes yes no explicit user.role == "readonly" refusal (403) in each handler — routes/memories.rs, routes/estate.rs
Orchestration Steer verbs: nudge / report / handoff / ask_commander, raise an escalation yes yes no WS choke on Role::max_verb() — Worker (User) clears Steer, ReadOnly is capped at Read
Operator-tier verbs: restart_container / any VerbClass::OperatorOk no — nobody no no refused at propose-time (undrivable_verbs()) AND structurally in the authorization PEP (operator_ok is always false for any principal resolved from a login, admin included)

Those two rows are where User and ReadOnly resolve differently. Everywhere else that checks a role at all, the check is binary — admin or not — and User and ReadOnly fall on the same side of it.

Detail and citations

The HTTP admin surface treats User and ReadOnly identically. auth/admin.rs's two guards — require_admin (soft: warn-and-allow when HIVEMIND_ENFORCE_ADMIN_ROLE=false, 403 when true, the default) and require_admin_hard (403/401, no opt-out) — both reduce to one check: user.role == "admin". Neither guard, nor anything upstream of it, ever asks whether the non-admin caller is user or readonly. This is true across every admin-mounted router: fleet, audit, bot-perf, updates, the manager-proxy and agent-chat admin mounts, routes/servers.rs's approval route (require_admin_hard), routes/admin_tools.rs's two decide routes (require_admin_hard), and estate_fleet.rs (require_admin_hard, with its own regression test asserting the guard is present in the mount). The assistant router (routes/assistant.rs) is also behind a soft require_admin at its own mount — noted here as an observation only; that gate was under active revision elsewhere as of this writing, so its internals are out of scope for this document and untouched here.

propose_dispatch is admin-only, and the check is stronger than "in the admin group." It resolves the caller through orchestration_authz::principal::user_is_commander, which is true only when the persisted user.role == "admin" — i.e. it checks the role already written to users.role at login time, not a live SSO group lookup, and it does not go through require_admin's policy function at all. That means propose_dispatch stays admin-only even on an instance that has HIVEMIND_ENFORCE_ADMIN_ROLE=false (the warn-only escape hatch that softens every require_admin-gated route does nothing here). A non-admin — user or readonly — cannot start orchestration work through this surface, full stop.

restart_container is refused for everyone, including an administrator, by two independent mechanisms. First, at propose time: propose.rs checks facet_persona::undrivable_verbs(), which includes RestartContainer (VerbClass::OperatorOk in orchestration_authz/verb.rs), and returns a refusal whose message says so explicitly: "is an operator-only action and cannot be confirmed by anyone through this surface — not even an administrator, because it needs an out-of-band grant no login carries." Second, structurally: principal_from_role_str — the function that turns any persisted role, including "admin", into an orchestration principal — always sets operator_ok: false, and the authorization PEP (orchestration_authz/spine.rs) denies any VerbClass::OperatorOk verb unless operator_ok is true. No login path can ever set that field. So even if the propose-time check were bypassed, the enforcement point behind it still refuses. This is a deliberate design (the comment calls it "the grant... no login carries"), not a gap.

The real User/ReadOnly split lives in the orchestration WS layer, not the HTTP admin surface. There are two distinct Role enums in this codebase: auth::role::Role (Admin/User/ReadOnly — the SSO-resolved role this document is about) and orchestration_authz::spine::Role (Commander/Manager/Worker/ReadOnly — "a Hivemind-native adaptation" of a ported model, per that module's own docs). principal_from_role_str bridges them: admin → Commander, coordinator → Manager, user → Worker, readonly → ReadOnly, and any unrecognized role string also fails closed to ReadOnly — worth flagging on its own: the orchestration layer's unmapped-role default is fail-closed to the most restricted tier, the exact opposite of auth/role.rs's SSO-arrival default, which fails open to User (see "The unmapped-user default" below). The two defaults serve different layers and are each independently correct for their own layer — but a reader who assumes one governs the other would be wrong, so it is stated here plainly. Role::max_verb() maps Worker → Steer and ReadOnly → Read; the Steer tier covers nudge, report, handoff, ask_commander, and enqueueing an escalation into the operator's queue. Live tests pin this both ways: a user-role caller's nudge succeeds (ws.rs), and the escalation-enqueue path is commented "anonymous and ReadOnly cannot flood the operator queue" — a plain user can raise one, readonly cannot.

Confirm/decide routes are unequally defended, independent of this ticket. Orchestration-escalation decisions (require_admin_hard) enforce never-self-approve atomically in SQL and have no env opt-out. MCP tool-approval decisions sit behind the soft require_admin (the one with the HIVEMIND_ENFORCE_ADMIN_ROLE opt-out) and — per that surface's own test file — had no auth test at all before one was added, and has no equivalent never-self-approve backstop. Both routes are admin-only either way, so this does not change who gets which role, but it is a real asymmetry an operator relying on "admin-gated = equally defended" should know about. Not fixed here — out of scope for HIVE-1025, flagged for separate follow-up.

Plain reads need only authentication, not a specific role. The baseline gate on most non-admin routes is require_api_key alone: any authenticated principal — admin, user, or readonly — passes identically. Role only starts to matter once a route sits behind an admin-mounted router or the WS Steer-tier choke described above.

Is anything single-user-by-accident?

Checked specifically for the multi-employee-on-one-instance shape: several people, one shared instance, concurrent use. Verdict: safe at the identity/attribution layer today.

  • No global "current user" singleton. SessionStore is a session-keyed map behind a lock, not a single-slot cache; AppState holds only genuinely shared config (release info, conformance status, metrics), never "the current user."
  • Every proposal, decision, and per-caller rate limit is attributed to the caller's own principal, derived from their own user.id (principal_from_user) — never a shared generic "admin" bucket. decided_by on an admin action is populated only from the verified User extension, and is regression-tested against header spoofing. MAX_PENDING_PER_PRINCIPAL and the escalation dedup key are enforced per-principal, not globally, so one busy employee cannot exhaust another's quota.
  • Owner-scoped listing is real and mutation-tested. list_for_principal filters by the server-derived principal, never a caller-supplied parameter, and has a dedicated mutation tripwire asserting a foreign principal's row is never returned.
  • A duplicate-identity bug of the same family is fixed. One person could resolve to two different principal rows (a local username and an email-based row) when the proxy and Facet disagreed on which row was canonical. That was not a collision between two people, but it is the kind of bug that would break multi-user isolation, and it is fixed in this release line.
  • One thing flagged, not a code defect: parts of the Commander surface are still worded for a single operator. The principal model underneath is per user, and nothing found suggests it would misattribute an action across administrators. Alert delivery was not examined for more than one administrator: see Approval-escalation path for where alerts go today.

Is ReadOnly meaningfully more restricted than User?

Yes, for content — and no, for administration. The distinction is the thing to get right before assigning roles, because it is not the one the names suggest.

On the entire HTTP admin surface — every administer action, propose_dispatch, and both confirm/decide routes — User and ReadOnly are the same bucket: both are simply "not admin," refused identically. Picking ReadOnly over User buys nothing there, because User had none of it either.

Where ReadOnly does bite is content. A read-only caller cannot create, update or delete a memory, and cannot ingest or delete a knowledge or vault document; each of those handlers refuses role == "readonly" with a 403. It also cannot nudge, report, hand off, ask the Commander, or raise an escalation. A User can do all of that.

So the practical reading for onboarding is: ReadOnly is a real look-but- don't-touch role for the knowledge and memory surfaces, and buys nothing extra against the admin surface. If your reason for choosing it is "this person should not change our recorded knowledge," it does that. If your reason is "this person should not reach administrative functions," User already could not.

The unmapped-user default

Out of scope to change here — recorded for the operator's decision. An SSO identity matching neither admin_group, user_group, nor readonly_group resolves to User today (fail-open, by design — see auth/role.rs's module docs and HIVE-672). Flipping that default to ReadOnly (fail-closed) is HIVE-672 R1 and is plausibly the right call for a multi-employee customer onboarding, but it is a behavior change on every existing self-hosted instance and is each operator's own call, not this ticket's: weigh it against the fact that, per the section above, ReadOnly today grants little beyond User on the HTTP surface, so the practical blast radius of getting this wrong in either direction is smaller than the "fail-open vs. fail-closed" framing alone suggests.