ADR 061: Standard Roles and Control-Plane Authorization¶
- Status: Accepted
- Type: Architecture
- Created: 2026-09-22
- Related-ADRs: 010 (security umbrella), 014 (authentication bootstrap), 050 (WAF; its deferred
authgate), 062 (the access gate, which names these roles)
Context¶
Hop3 authenticates every control-plane call and authorizes almost none of them. The RPC dispatcher verifies the caller's token and stops there. A handful of commands, all in user management and email, call require_admin() from inside their own body. Every app, addon, env, domain and backup command runs for any authenticated caller. The dashboard behaves the same way. Its controllers check that a user is signed in and never check a role.
The security model records the consequence: a Hop3 account is an operator-equivalent credential (notes/security/security-model.md §1.4). That was an acceptable description of a server with one user. It stops being acceptable once an account exists for a narrower purpose. The access gate (ADR 062) creates that purpose: someone who may see an app must not be able to deploy it, destroy it or read its secrets. Without authorization on the control plane, an account created to pass a gate can run env show on every app on the server.
The data model already carries roles: Role and a user-role association table. In practice only one role name, admin, is ever created. The names are free-form strings. Nothing enforces anything against them except the scattered require_admin() calls.
Decision¶
1. A fixed set of standard roles¶
Hop3 defines four roles. They belong to the platform: no command creates, renames or deletes one.
| Role | Control plane | Passes an access gate |
|---|---|---|
admin |
Everything, including users, role grants and server-wide settings (email, certificates, named networks, WAF state). | Yes |
deployer |
App lifecycle, configuration and addons for every app: deploy, destroy, env, addon, domain, backup, logs. No user or server administration. |
Yes |
viewer |
Read-only state: which apps exist, their status, processes and domains. No command whose output can carry a secret (env show, logs, addon credentials, backup contents). |
Yes |
member |
None. The account can sign in to the web UI and nothing else. | Yes |
The roles are totally ordered: admin > deployer > viewer > member. Each includes everything the roles below it grant. A command declares the minimum role it needs; a gate declares the minimum role it admits.
Custom roles are out of scope. With a standard vocabulary, a recipe written against one server means the same thing on every other. An operator can take in a fixed ladder at a glance. Custom roles are the last resort for a need these four cannot express. No such need has been shown.
2. Every account has exactly one server role¶
A user holds one server role, stored on the user. There is no "no role" state. member is the floor: a new account is member unless created with a higher one. This replaces the free-form Role table and its association table, which modelled a many-to-many relation the platform never needed.
Only an admin can change another account's role. The last active admin cannot be demoted or disabled.
3. Authorization lives in the dispatcher and denies by default¶
Each command class declares min_role, beside the requires_auth and destructive attributes it already carries. The RPC dispatcher reads the caller's role and refuses the call before the command runs. No command body decides authorization for itself; the existing require_admin() calls become min_role = Role.ADMIN declarations.
The base-class default is admin. A command added without a declaration is therefore admin-only, which fails safe. Review sees it too. A test compares each registered command's effective minimum role with a checked-in table. Any change to who can run what then shows up in a diff.
The dispatcher reads the caller's role from the database on every request. The token never supplies it. A demotion or a disabled account takes effect on the next call, without revoking outstanding tokens. A token whose subject names no account is refused, however it was signed. Test harnesses obtain their tokens the way hop3 login --ssh does, for an account that exists. The existing token scopes (authenticated, admin) carry no authorization meaning.
auth get-token refuses a member. The account can run no command, so a token for it could do nothing, and would look like a broken one. Once per-app grants exist (§5), a member who holds one can obtain a token.
A denied call returns an ordinary structured RPC error naming the command, the role it needs and the caller's role. It is distinct from the unauthenticated error, so a client can tell "sign in" from "ask an admin".
4. The dashboard uses the same rule¶
Every dashboard route declares a minimum role through the same mechanism: pages that read state need viewer, forms that change it need the role the equivalent RPC command needs. A member who signs in sees their profile and nothing else.
5. Designed for per-app roles¶
Per-app roles are the next step. The design above is chosen so they extend it:
- A grant will pair a user, a standard role and an app. The server role from §2 is the grant whose scope is the whole server.
- A user's effective role for an app is the higher of their server role and their grant for that app. A
membergranteddeployeron one app can deploy that app and nothing else. - The dispatcher already resolves the app a command targets (
--appis always a flag), so the check moves from "caller's role" to "caller's effective role for this app" in one place. - The role names do not change: the same four words work at both scopes.
Per-app roles still need a grant table and a migration deciding who holds which apps on an existing server. This ADR provides neither.
6. What the roles do not cover¶
Three paths reach the control plane without passing through the dispatcher's check, and the roles say nothing about them:
- Build steps run recipe code as
hop3. Abefore-buildhook or a package-manager install script executes with the control plane's identity, which reaches rootd and can read the key that signs tokens. Adeployercan therefore mint anadmintoken through a recipe, and the two roles are equivalent until builds drop to the app user (ADR 055, open question 6).viewerandmemberhold either way: neither can start a build. git pushis authorized by SSH key. The post-receive hook runshop3-server git-hookunder thehop3user's forced command, with no token and no account. Holding a key inhop3'sauthorized_keysis permission to deploy any app. Mapping keys to accounts belongs with per-app grants.- Commands run on the host through
hop3-servercall command classes directly. They need a shell on the server, which is outside this model.
7. Migration¶
Existing users with the admin role become admin. Every other existing user becomes deployer, which preserves the access they have today. The migration drops the Role and association tables.
Consequences¶
Positive¶
- A Hop3 account stops being operator-equivalent by construction. The statement in the security model becomes "an account can do what its role grants".
- One enforcement point. A command cannot forget to check. The checked-in table puts the whole policy in one reviewable file.
- The access gate has an account to admit that holds no control-plane authority (
member).
Negative¶
- Every command needs a correct
min_role. The admin-only default makes a missed one fail closed, so the mistake surfaces as a denied call. Someone still has to diagnose it. - A per-request role lookup adds one indexed read to every RPC call.
- A fixed ladder cannot express "may read logs but not deploy". That is the price of a standard vocabulary. Custom roles stay available as a later extension for this reason.
Alternatives considered¶
Operator-defined roles with per-command permissions. Buys arbitrary policies. Costs a permission vocabulary to design, document and keep stable across releases. Recipes also stop being portable: a recipe naming editors means nothing on a server without it. The choice turns on a real need the four standard roles cannot express.
Keep checks inside commands. Buys no dispatcher change. Costs the property that matters: a new command is open unless its author remembers. This is how the current gap arose.
Carry the role in the token. Buys a stateless check. Costs revocation: a demoted user keeps their old authority until the token expires. The choice turns on whether the per-request lookup ever shows up as a measurable cost.
Open questions¶
- Which read commands belong to
viewer. The line is "no output that can carry a secret". Logs are the hard case: most are harmless and some print connection strings. This ADR puts logs atdeployer; the per-command table is where that is decided and reviewed.
Related ADRs: ADR 010: Security and Resilience (Umbrella), ADR 014: Authentication Bootstrap Process, ADR 050: Layer-7 Web Application Firewall (LeWAF), ADR 062: Authenticated Access Gate