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.ReadOnlyis refused explicitly in two more places: content writes (memoriesPOST/PATCH/DELETE, and the knowledge/vaultingest,vault_ingest,from_url,events_ingestanddelete_docroutes each refuse a read-only caller with403), and a narrower slice of the orchestration WS layer (aUsercan nudge/report/handoff/ask-commander and raise an escalation into the operator's queue; aReadOnlycannot). - Nobody — including Admin — can
restart_container(or any otherVerbClass::OperatorOkverb) 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.
SessionStoreis a session-keyed map behind a lock, not a single-slot cache;AppStateholds 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_byon an admin action is populated only from the verifiedUserextension, and is regression-tested against header spoofing.MAX_PENDING_PER_PRINCIPALand 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_principalfilters 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.