Coppice’s original auth surface was a flat list of bearer tokens
declared via COPPICE_API_KEYS. The multi-tenant model
layered on top of that adds first-class users, per-user API tokens,
role-based permission grants, and optional TOTP — without breaking
any existing single-bearer deployment. See
API key authentication for
the legacy surface; this page covers the multi-user model.
What ships in v1
- Local realm — every user belongs to a string-named realm. Phase 1
uses
“local”for every row; Phase 4 will introduce LDAP and OIDC realms with the same wire shape. - Users + per-user API tokens. Tokens are argon2id-hashed at rest; the plaintext is shown exactly once at create time.
- Built-in roles —
admin,operator,viewer— seeded the first time the auth document is loaded. Custom roles can be added viaPOST /roles. - Role grants carry a scope:
Global,Tag(name), orSandbox(id). Permission globs are two-segment (sandboxes:create,*:read,*). - TOTP (Phase 3) — RFC-6238 SHA-1, six digits, 30-second period.
Secrets are AES-GCM-encrypted at rest with a host-local key at
/var/lib/coppice/auth.key(0600).
Phase 2 (groups) and Phase 4 (LDAP/OIDC) are deliberately out of scope for this drop. The on-disk shape leaves room for both.
Activation
The middleware activates when either:
COPPICE_AUTH_MULTI_USERis set to a truthy value (1,true,yes,on), or/var/lib/coppice/auth.jsoncontains at least one user.
Until activation the gateway falls back to the legacy
COPPICE_API_KEYS path: existing single-tenant deployments
keep working with no config change.
Storage
Single source of truth: /var/lib/coppice/auth.json
(rewritten atomically — tmpfile + rename, same dance as the durable
snapshot registry in backend/freebsd_jail.rs). The TOTP
encryption key lives at /var/lib/coppice/auth.key and is
generated on first run if absent (32 bytes, 0600).
API surface
GET /users
POST /users (admin once one user exists)
GET /users/:id
PATCH /users/:id (admin or self)
DELETE /users/:id (admin)
POST /users/:id/tokens -> 201 with { token, secret }; secret shown once
GET /users/:id/tokens
DELETE /users/:id/tokens/:token_id
GET /roles
POST /roles (admin)
POST /users/:id/totp/enroll -> { provisioning_url, enrollment_secret }
POST /users/:id/totp/verify -> 204 on success
DELETE /users/:id/totp (admin or self)
POST /auth/login username + password + totp? -> session bearer
POST /auth/logout revokes the bearer used for the request
GET /auth/whoami (existing — extended to surface Actor when set)
Login flow (local realm)
Coppice doesn’t store user passwords — they’re realm-side. For the
local realm, POST /auth/login takes
{ username, password, totp? } where password
is one of the user’s existing API tokens. On success the gateway
issues a fresh 12-hour session token and returns it once.
Phase 4 (LDAP / OIDC) will fill in real password auth — same wire shape, different realm.
Migration path
- Existing host: nothing changes. Leave
COPPICE_API_KEYSin place; the multi-user store stays empty. - Bootstrap the first admin: while the legacy bearer is still active,
POST /userswith an admin role grant. ThenPOST /users/:id/tokensto mint the first multi-user bearer. - Flip
COPPICE_AUTH_MULTI_USER=1in the rc.d env (or wait for the user count to drive activation automatically). - Issue tokens for the rest of your team via
POST /users/:id/tokens.
Permission model
Each RoleGrant attaches a role to a scope; the
permission check at request time evaluates:
Globalgrants always apply.Tag(name)grants apply when the requested scope is the same tag (or a sandbox carrying that tag — the tag-membership lookup happens at the call site).Sandbox(id)grants apply only to that sandbox.
Built-in role permissions:
| role | permissions |
|---|---|
| admin | * |
| operator | sandboxes:*, tasks:read, diagnostics:read, templates:read, volumes:*, snapshots:*, secrets:read |
| viewer | *:read |
TOTP
- Enrolment is two-step:
POST /users/:id/totp/enrollreturns a fresh 20-byte secret (base64url) plus theotpauth://provisioning URL; the gateway does NOT persist the secret yet. - The caller renders the URL as a QR, scans it with their authenticator,
and POSTs the displayed code (plus the
enrollment_secretfrom step 1) back toPOST /users/:id/totp/verify. - Only after the verify succeeds does the encrypted secret land on disk. A half-finished enrolment leaves no trace.
POST /auth/loginrejects with403 totp requiredif the user has TOTP enrolled and the request omits atotpfield.
Crates
argon2— token-secret hashing (and Phase 4 password hashing).aes-gcm— TOTP secret-at-rest encryption.totp-rs— RFC-6238 codes + provisioning URL.ldap3— Phase 4 LDAP realm client.openidconnect— Phase 4 OIDC id-token verifier.
All five are pure-Rust + Apache-2.0 / MIT.
Phase 2 — groups
A Group is a named set of users with role grants. Members inherit
every grant in the group’s role_grants at the grant’s scope; a user’s
effective permissions are the union of their direct grants and the
grants from every group they belong to, deduped by (role, scope).
The on-disk shape is additive — auth.json files written by Phase 1
don’t carry a groups field; loaders fill in [] via
#[serde(default)]. No version bump.
{
"id": "8e8e…",
"name": "team-a",
"description": "the team-a admins",
"members": ["c92f…"],
"role_grants": [
{ "role": "operator", "scope": { "kind": "Tag", "value": "team-a" } }
],
"created_at": "2026-04-23T17:00:00Z"
}
API surface
GET /groups list
POST /groups { name, description? }
GET /groups/:id detail
PATCH /groups/:id rename / desc
DELETE /groups/:id
POST /groups/:id/members/:user_id add member
DELETE /groups/:id/members/:user_id remove
POST /groups/:id/grants { role, scope }
DELETE /groups/:id/grants/:grant_index remove
All admin-gated (users:write).
Tag-scoped ACL evaluation
Actor::can_with_tags(action, scope, sandbox_tags) extends the Phase 1
matcher so a Scope::Tag(t) grant covers a Scope::Sandbox(s) request
whenever the sandbox carries the tag t. Callers that already have
the sandbox row in hand pass &sandbox.tags; those with only a sandbox
id (or no sandbox at all) call Actor::can(action, scope) and get the
old flat behaviour. Coverage table:
| grant scope | requested scope | covered? |
|---|---|---|
Global | anything | yes |
Tag(t) | Tag(t) | yes |
Tag(t) | Sandbox(s) | iff s has tag t |
Sandbox(g) | Sandbox(g) | yes |
| anything else | anything else | no |
Worked example: a user in group g1 with grant operator on Tag(team-a)
calls DELETE /sandboxes/:id against a sandbox carrying the team-a
tag. Actor::can_with_tags("sandboxes:delete", Sandbox(id), &["team-a"])
returns true; the same call against a sandbox without that tag
returns false.
The new helper auth::middleware::require_action(actor, action, sandbox)
wires this for handlers that already hold a Sandbox reference; passing
None falls back to a Scope::Global check.
Phase 4 — pluggable realms
Realms are defined as an [async_trait] object (auth/realm.rs) with
three implementations:
- Local — the Phase 1 contract: the
passwordslot in/auth/loginis one of the user’s existing API tokens. - LDAP — two-bind flow against
ldap://orldaps://. Service account searches byuser_filtersubstituting{username}; on a single hit, a second bind verifies the user’s password. Optional group enumeration viamemberOfand/or agroup_search_base+group_filterlookup. - OIDC — verifies an id-token’s signature and claims via the
provider’s discovery JWKS. The frontend runs the auth-code redirect;
the gateway only sees a verified token in
/auth/login.
Config file
Operator-managed JSON at /var/lib/coppice/auth-realms.json:
[
{
"kind": "ldap",
"config": {
"id": "ldap-corp",
"server": "ldap.example.com",
"port": 636,
"use_tls": true,
"bind_dn": "uid=svc-coppice,ou=services,dc=example,dc=com",
"bind_password_env": "COPPICE_LDAP_BIND_PASSWORD",
"user_search_base": "ou=people,dc=example,dc=com",
"user_filter": "(uid={username})",
"group_search_base": "ou=groups,dc=example,dc=com",
"group_filter": "(member={user_dn})"
}
},
{
"kind": "oidc",
"config": {
"id": "oidc-google",
"issuer_url": "https://accounts.google.com",
"client_id": "...",
"client_secret_env": "COPPICE_OIDC_GOOGLE_SECRET",
"redirect_uri": "https://coppice.example.com/auth/oidc/callback",
"scopes": ["openid", "email", "profile"]
}
}
]
Note the *_env indirection: realm secrets live in the
gateway’s host env (one variable per realm), not in the on-disk
config doc.
Login wire shape
POST /auth/login
# local + ldap
{ "username": "alice", "password": "...", "realm": "ldap-corp", "totp": "123456" }
# oidc
{ "idToken": "<verified id-token>", "realm": "oidc-google" }
Realm defaults to local when omitted. The totp gate
applies regardless of realm — once a user has TOTP enrolled, every
login passes through verify_totp after the realm authenticates.
JIT provisioning
Successful auth against a non-local realm calls
AuthStore::find_or_create_external_user(realm_id, canonical_username, …).
The first time a given (realm, username) pair shows up the gateway
creates a new User row with no roles; an admin grants permissions
via PATCH /users/:id. Idempotent: subsequent logins re-use the
existing record.
Canonical usernames take the form “<sub-or-uid>@<realm-id>”
so two realms can’t collide on plain usernames.
Inventory
GET /auth/realms
[
{ "id": "local", "kind": "local", "display_name": "Local users" },
{ "id": "ldap-corp", "kind": "ldap", "display_name": "Corp LDAP",
"login_hint": "ldaps://ldap.example.com:636" },
{ "id": "oidc-google", "kind": "oidc", "display_name": "Google",
"login_hint": "https://accounts.google.com" }
]
Surfaced in the admin UI’s Permissions → Realms panel. Read-only — operator drops new entries into the JSON file and restarts the gateway.