Security & Access Control (RBAC) — design spec
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/superpowers/specs/rbac-access-control.md. Edit at the source, not here.
Security & Access Control (RBAC) — design spec
Section titled “Security & Access Control (RBAC) — design spec”Date: 2026-06-27 Status: proposed — awaiting owner (Tony) review BEFORE any build Scope: Task 6 — local users CRUD, RBAC roles/permissions, sessions, audit, hardening Risk class: security-critical, hard-to-revert. Do not implement from this document; it exists to be reviewed and amended first.
1. Problem statement
Section titled “1. Problem statement”SynVirt authenticates every operator against the host’s PAM stack and then treats them all identically: any authenticated principal has the full API surface. There is no notion of who may do what. An MSP that hands a junior admin a login cannot stop them deleting a production VM; a read-only auditor cannot be given a safe view; a compromised password is a full-host compromise with no session list to revoke and no per-action audit trail surfaced anywhere.
The product needs, modelled as data, not hardcoded walls (CLAUDE.md §4.5 / §8 — “no hardcoded restrictions … soft warnings, blockers reserved for what literally breaks”):
- Local users — CRUD for operator accounts (today only PAM login + installer-seeded accounts exist).
- RBAC — roles (admin / operator / read-only / custom) composed from a permission catalog, assignable to users; enforced uniformly.
- Auth seam — local auth now, with a clean boundary so LDAP / Kanidm slot in later without rewriting handlers.
- Sessions — list active sessions, force-logout, configurable idle timeout.
- Hardening — SSH toggle, host firewall, a lockdown-mode equivalent, an audit-logging toggle, brute-force soft-lockout, and an MFA seam.
This is the highest-blast-radius subsystem in the roadmap: a bug that locks every operator out of a remote host, or that silently grants instead of denies, is a sev-0. Hence the review gate.
2. What exists today (cited)
Section titled “2. What exists today (cited)”2.1 Authentication middleware
Section titled “2.1 Authentication middleware”crates/daemon/src/auth.rs — require_basic_auth is the single axum
middleware in front of every mutating route. It accepts three credential
channels in preference order:
- A valid
synvirt_sessionsession cookie (already implemented — the CLAUDE.md §3 “session-cookie open item” is stale). Authorization: Basic <b64>→ validated against PAM.?auth=<base64(user:pass)>query fallback (forEventSource/<img>which cannot set headers).
PAM runs through pam_authenticate (auth.rs:304), serialized behind a
process-wide PAM_LOCK (glibc NSS is non-reentrant), service name
synvirt-daemon (/etc/pam.d/synvirt-daemon, fallback login). A
SHA-256 keyed credential cache (CRED_CACHE, 60 s TTL, 64-entry cap)
keeps a burst of Basic-Auth requests from re-paying yescrypt per call.
The authenticated principal is injected as AuthUser(pub String)
(auth.rs:47) into request extensions; handlers read it via
Extension<AuthUser>.
There is no role or permission concept anywhere. Authorization is
binary: SessionMode::Admin = full API, SessionMode::Setup =
setup-only.
2.2 Session store
Section titled “2.2 Session store”crates/daemon/src/api/session.rs — SessionStore (an in-memory
Mutex<HashMap<String, Session>>) with:
mint(principal, mode) -> String(32-byte URL-safe token),validate(token) -> Option<Session>(refresheslast_touched, evicts past the 12 h idle TTLSESSION_TTL),revoke(token)— already present, so force-logout is one call away.
Session { principal, created_at, last_touched, mode },
SessionMode { Setup, Admin }. The store is constructed in main.rs
as sessions: SessionStore::new() and lives on AppState
(crates/daemon/src/api/health.rs). The cookie is synvirt_session,
HttpOnly; Secure; SameSite=Lax; Max-Age=2592000 (auth.rs:138,236).
State is process-memory only — a daemon restart drops every session
(operators silently re-login on next request via Basic Auth). No
/api/v1/sessions list/force-logout route exists.
2.3 Audit store
Section titled “2.3 Audit store”crates/synvirt-time/src/audit.rs — AuditStore is the canonical
sled-backed audit log, opened once in main.rs against
/var/lib/synvirt/settings-audit.db/ and passed to every settings
service (the “single sled handle” invariant, MEMORY.md). Surface:
AuditStore::open(&Db) -> Result<Self>(treeaudit/settings),record(&AuditRecord) -> Result<()>(inverted-timestamp key, newest first),list_recent(limit) -> Result<Vec<AuditRecord>>.
AuditRecord { ts_ms, actor, endpoint, body_hash, outcome, before, after }, AuditOutcome { Success, Failure, Rollback }. No REST
endpoint exposes it today — it is write-only, consumed by no handler.
2.4 Hardening reuse surfaces (already shipped)
Section titled “2.4 Hardening reuse surfaces (already shipped)”- Firewall —
synvirt-firewall::FirewallService(nftablesinet synvirttable) withget / set_enabled / add_rule / update_rule / delete_rule / move_rule / test, mounted under/api/v1/settings/firewall*. Every mutation already takesactor: &strand records to the sharedAuditStore. - SSH keys —
crates/daemon/src/settings/ssh_keys.rs::SshKeysService(AUTHORIZED_KEYS_PATH = /root/.ssh/authorized_keys) withlist / add / removeby SHA-256 fingerprint, audited. - Services —
synvirt-services::allowlistPLATFORMarray listsssh.service,nftables.service,libvirtd.service; the Services panel can already enable/disable/start/stop allowlisted units. An “SSH on/off” toggle isssh.servicethrough this path — no new systemd plumbing.
2.5 Local users today
Section titled “2.5 Local users today”No daemon-side user CRUD exists. The only useradd/chpasswd code is
installer chroot seeding (crates/daemon/src/api/install.rs) and
cluster-peer provisioning (crates/daemon/src/api/cloud.rs, which has
the reusable valid_unix_username, RESERVED_USERNAMES,
unix_user_exists helpers). Authentication is “whatever PAM says about
a real Unix account.”
2.6 Conventions to honor
Section titled “2.6 Conventions to honor”ApiError (crates/daemon/src/api/error.rs): bad / not_found / conflict / unauthorized / internal, machine codes E_*, JSON envelope
{ error: { code, message, trace_id, details } } + X-Trace-Id.
Settings sections return SettingsEnvelope<T> { data, warnings, meta }
(crates/daemon/src/settings/envelope.rs). One crate per domain
(CLAUDE.md §4.5).
3. Proposed design
Section titled “3. Proposed design”3.1 Crate placement — new synvirt-identity
Section titled “3.1 Crate placement — new synvirt-identity”A single new crate owns the identity domain end-to-end (one-crate-per-
domain invariant). Name synvirt-identity (preferred over
synvirt-rbac because the crate owns users + sessions + roles, not just
role mapping). It performs no HTTP; the daemon mounts thin handlers
that delegate, exactly like synvirt-firewall.
synvirt-identity/ src/ lib.rs IdentityService façade (constructed in main.rs) users.rs local-user records + CRUD roles.rs Role + Permission catalog + assignment authz.rs permission check: (principal, Permission) -> Decision backend.rs AuthBackend trait (the LDAP/Kanidm seam) pam_backend.rs PamBackend: wraps the existing pam_authenticate path lockout.rs brute-force soft-lockout counter error.rs one thiserror enum, E_* discriminants store.rs sled trees (users, roles, assignments)State store: sled at /var/lib/synvirt/identity.db/ (per the
/var/lib/synvirt/<feature>.db/ convention, CLAUDE.md §4.1). The audit
trail reuses synvirt-time::AuditStore (passed in by main.rs, never
re-opened) — optionally on a dedicated tree audit/security so security
events filter cleanly from settings events, but the same Db handle.
3.2 Authorization model — data, not walls
Section titled “3.2 Authorization model — data, not walls”The enforcement primitive is a permission catalog: a flat, additive
list of capability strings the daemon understands. Permissions are
namespaced domain:verb[:scope], e.g.:
vm:read vm:create vm:delete vm:powervm:edit-hardware vm:console vm:snapshot vm:migratestorage:read storage:write network:read network:writesettings:read settings:write security:read security:writehost:read host:reboot backup:read backup:writeA role is a named set of permissions:
/// A named, assignable set of permissions. `builtin` roles ship with/// the product and cannot be deleted; their permission sets are still/// data (editable by a `security:write` holder) so an operator can/// loosen or tighten them without a code change.pub struct Role { pub id: String, // slug, e.g. "operator" pub display_name: String, pub description: String, pub permissions: BTreeSet<Permission>, pub builtin: bool, // admin/operator/read-only seed roles}Shipped seed roles (seeded into sled on first start, then owned by data — editable, not recompiled):
| Role | Permissions |
|---|---|
admin |
* (every permission, including security:write) |
operator |
every vm:*, storage:*, network:read, backup:write, *:read — no security:write, no host:reboot |
read-only |
every *:read only |
Enforcement (authz.rs) is one pure function:
/// Returns whether `principal` holds `needed`. A principal with the/// `admin` role (or an explicit `*` grant) always passes. There is no/// hardcoded `if user == "root"` — root's power comes from holding the/// admin role in data, which an operator could in principle re-scope.pub fn decide(grants: &PermissionSet, needed: Permission) -> DecisionWired into axum as a small extractor/guard layered after
require_basic_auth (which already produced AuthUser). A handler
declares its requirement once:
// pseudocode — the guard reads AuthUser, loads the principal's effective// PermissionSet from IdentityService, and 403s with E_FORBIDDEN if absent.async fn delete_vm(_: Require<{ Permission::VmDelete }>, ...) { ... }No-hardcoded-restriction discipline: the catalog is the only place a capability is named; whether a user has it is always a data lookup. Password policy (length/complexity), max sessions, lockout thresholds are config with soft-warning defaults, never compiled limits.
3.3 Local users + the auth-backend seam
Section titled “3.3 Local users + the auth-backend seam”/// The seam future directory backends implement. PamBackend is the only/// implementation at launch; an LdapBackend / KanidmBackend lands later/// without touching handlers or the authz layer.#[async_trait]pub trait AuthBackend: Send + Sync { /// Verify a credential, returning the canonical principal id. async fn authenticate(&self, user: &str, pass: &str) -> Result<Principal, AuthError>; /// Whether this backend can also enumerate/manage accounts (PAM: /// yes via shadow; LDAP: read-only directory). fn capabilities(&self) -> BackendCaps;}PamBackend wraps the existing auth::pam_authenticate (move it
into the crate or call back via a function pointer — do not duplicate
the PAM lock; the serialization invariant must hold process-wide). A
local user is the join of a real Unix account (PAM authenticates it) and
a SynVirt identity record holding its role assignments + metadata:
pub struct User { pub username: String, // matches the Unix account PAM knows pub display_name: String, pub roles: Vec<String>, // role ids; effective perms = union pub enabled: bool, // soft-disable without deleting the Unix acct pub mfa: Option<MfaEnrollment>, // None today; seam only pub created_at: i64, pub source: UserSource, // Local | Ldap | Kanidm (future)}Creating a local user provisions the Unix account (reuse
cloud.rs::valid_unix_username + RESERVED_USERNAMES +
unix_user_exists, useradd/chpasswd) and writes the identity
record. Deleting is soft by default (enabled=false); hard delete is a
separate, security:write-gated, typed-confirm action.
3.4 Sessions
Section titled “3.4 Sessions”Extend SessionStore minimally:
- carry the resolved
PermissionSet(or role ids) onSessionso per-request authz is a map lookup, not a sled read; - add
list() -> Vec<SessionView>and use the existingrevokefor force-logout; - make
SESSION_TTLconfigurable (settings:write), default 12 h, soft-warn below 5 min / above 30 days rather than reject.
Open decision (§5): in-memory sessions are lost on restart. For “list/force-logout that survives a daemon bounce” the store must move to sled (or be mirrored). Recommend: keep in-memory for the hot path, mirror minted/revoked tokens to a sled tree so the list is durable and force-logout is honored after a restart.
3.5 Hardening surface
Section titled “3.5 Hardening surface”| Control | Mechanism | Reuse |
|---|---|---|
| SSH on/off | enable/disable ssh.service |
synvirt-services allowlist (already lists ssh.service) |
| SSH keys | add/remove authorized_keys | settings/ssh_keys.rs::SshKeysService (exists) |
| Host firewall | nftables rules | synvirt-firewall::FirewallService (exists) |
| Lockdown mode | a host-state flag that flips a curated set: disable SSH, deny non-loopback API except dashboard, require re-auth, drop all sessions | new flag in synvirt-identity, composed from the above — no new enforcement primitive |
| Audit-logging toggle | on/off + retention for AuditStore writes |
synvirt-time::AuditStore (exists; add toggle) |
| Brute-force soft-lockout | per-principal failure counter with backoff; soft (escalating delay) not a hard permanent ban | new lockout.rs; pairs with the existing CRED_CACHE failure path which already declines to cache failures |
| MFA | MfaEnrollment seam on User; TOTP verify step inserted between PAM success and session mint |
seam only at launch |
Lockdown must have an out-of-band escape: the post-install Console
TUI (tty1, crates/console) can already toggle services and reset
network; it must be able to lift lockdown so a misconfigured remote
host is never bricked.
3.6 API surface
Section titled “3.6 API surface”All under PAM/session auth + the new authz guard. Envelope:
SettingsEnvelope<T> for settings-style reads, plain ApiError codes
on failure.
# Users (security:write to mutate, security:read to list)GET /api/v1/usersPOST /api/v1/users create local user (+ Unix acct)GET /api/v1/users/{username}PATCH /api/v1/users/{username} display name, enabled, rolesDELETE /api/v1/users/{username} soft by default; ?hard=true typed-confirmPOST /api/v1/users/{username}/password set/reset (chpasswd)POST /api/v1/users/{username}/mfa enroll (future seam)
# RolesGET /api/v1/rolesPOST /api/v1/roles custom roleGET /api/v1/roles/{id}PUT /api/v1/roles/{id} edit permissions (builtin: perms editable, not deletable)DELETE /api/v1/roles/{id} custom onlyGET /api/v1/permissions the catalog (for the UI to render checkboxes)
# SessionsGET /api/v1/sessions active sessions (principal, age, idle, source)DELETE /api/v1/sessions/{token} force-logout oneDELETE /api/v1/sessions?user={u} force-logout all of a principalGET /api/v1/sessions/policy idle-timeout etc.PUT /api/v1/sessions/policy
# AuditGET /api/v1/audit/log cursor-paginated AuditStore.list_recent, filterable by actor/outcome/endpointGET /api/v1/audit/policy enabled + retentionPUT /api/v1/audit/policy
# HardeningGET/PUT /api/v1/settings/security/ssh (delegates to services + ssh_keys)GET/PUT /api/v1/settings/security/lockdownGET/PUT /api/v1/settings/security/lockoutNew error codes: E_USER_EXISTS, E_USER_NOT_FOUND,
E_USER_RESERVED, E_ROLE_BUILTIN_DELETE, E_ROLE_NOT_FOUND,
E_FORBIDDEN (403), E_SESSION_NOT_FOUND, E_LOCKED_OUT (429),
E_LAST_ADMIN (refuse to delete/demote the final admin-holding user).
4. Phased rollout
Section titled “4. Phased rollout”Each phase ships and is reviewable on its own; authz is introduced fail-open-then-enforce so a bug can’t lock everyone out mid-rollout.
- Phase 0 — read-only foundations (safe). New crate skeleton +
sled store + seed roles + the permission catalog +
GET /permissions,GET /roles,GET /sessions(list only, no enforcement), andGET /api/v1/audit/log(just expose the already-writtenAuditStore). Zero behavior change to existing auth. This is the recommended first build. - Phase 1 — users + sessions management. Local user CRUD, password
reset, force-logout (uses existing
revoke), configurable idle timeout, session durability to sled. - Phase 2 — authz enforcement (the careful one). Add the
Require<Permission>guard to handlers in report-only mode first (log “would deny”, never block), validate against real traffic, then flip to enforcing behind a host flag with the TUI escape hatch. - Phase 3 — hardening composition. Lockdown mode, audit toggle + retention, brute-force soft-lockout.
- Phase 4 — directory seam. Land
AuthBackendwith anLdapBackend/KanidmBackendimplementation; users gainsource = Ldap. MFA TOTP verify step.
5. Risks & open questions
Section titled “5. Risks & open questions”- Lockout / brick risk. Any enforcement bug on a remote host with
no console is a sev-0. Mitigation: Phase-2 report-only mode, a
permanent
E_LAST_ADMINguard, and the Console TUI escape hatch are non-negotiable. - PAM vs SynVirt user duality. A “local user” is a Unix account + an identity record. They can drift (account deleted out-of-band). Decide reconciliation: treat the Unix account as source of truth for existence, the identity record for authz; surface orphans as warnings (soft), never hard errors.
- Session durability. In-memory
SessionStoremeans force-logout and the session list don’t survive a restart. Resolve in Phase 1 (mirror to sled) — flagged in §3.4. - Audit store sharing. Reusing
audit/settingsvs a newaudit/securitytree on the sameDb. Recommend a second tree to keep filtering clean; confirm the single-handle invariant is respected (open once inmain.rs). rootand the no-hardcoded-restriction rule. Modeling root’s power as “holds the admin role in data” is philosophically correct but lets an operator footgun themselves out of admin. TheE_LAST_ADMINguard + TUI recovery are the safety net; confirm Tony wants root re-scopable at all, or pinned as an un-droppable super-admin.- Cluster scope. Are users/roles host-local or cluster-replicated?
The cluster already replicates state via
synvirt-cluster(openraft). v1 recommendation: host-local identity; cluster-wide identity is a later, explicit phase (it touches the Raft state machine — high blast radius). - MFA storage. TOTP secrets are sensitive; reuse
synvirt-bmc::EncryptionKey(AES-256-GCM, already the canonical host key) rather than inventing a second keystore.
6. What’s safe to build first
Section titled “6. What’s safe to build first”Phase 0. It is purely additive and read-only: stand up
synvirt-identity with the sled store, seed the three builtin roles +
permission catalog, and expose GET /api/v1/permissions, GET /api/v1/roles, GET /api/v1/sessions, and — highest immediate value —
GET /api/v1/audit/log over the already-populated
synvirt-time::AuditStore. This gives operators a session list and an
audit view (today both invisible) with zero change to the
authentication path and zero enforcement, so it cannot lock anyone
out. Everything that can deny a request (Phase 2) waits until the data
model is proven in production read-only.