Multi-tenant auth

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

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:

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

  1. Existing host: nothing changes. Leave COPPICE_API_KEYS in place; the multi-user store stays empty.
  2. Bootstrap the first admin: while the legacy bearer is still active, POST /users with an admin role grant. Then POST /users/:id/tokens to mint the first multi-user bearer.
  3. Flip COPPICE_AUTH_MULTI_USER=1 in the rc.d env (or wait for the user count to drive activation automatically).
  4. 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:

Built-in role permissions:

rolepermissions
admin*
operatorsandboxes:*, tasks:read, diagnostics:read, templates:read, volumes:*, snapshots:*, secrets:read
viewer*:read

TOTP

Crates

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 scoperequested scopecovered?
Globalanythingyes
Tag(t)Tag(t)yes
Tag(t)Sandbox(s)iff s has tag t
Sandbox(g)Sandbox(g)yes
anything elseanything elseno

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:

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.