Skip to content

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.


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”):

  1. Local users — CRUD for operator accounts (today only PAM login + installer-seeded accounts exist).
  2. RBAC — roles (admin / operator / read-only / custom) composed from a permission catalog, assignable to users; enforced uniformly.
  3. Auth seam — local auth now, with a clean boundary so LDAP / Kanidm slot in later without rewriting handlers.
  4. Sessions — list active sessions, force-logout, configurable idle timeout.
  5. 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.


crates/daemon/src/auth.rsrequire_basic_auth is the single axum middleware in front of every mutating route. It accepts three credential channels in preference order:

  1. A valid synvirt_session session cookie (already implemented — the CLAUDE.md §3 “session-cookie open item” is stale).
  2. Authorization: Basic <b64> → validated against PAM.
  3. ?auth=<base64(user:pass)> query fallback (for EventSource / <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.

crates/daemon/src/api/session.rsSessionStore (an in-memory Mutex<HashMap<String, Session>>) with:

  • mint(principal, mode) -> String (32-byte URL-safe token),
  • validate(token) -> Option<Session> (refreshes last_touched, evicts past the 12 h idle TTL SESSION_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.

crates/synvirt-time/src/audit.rsAuditStore 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> (tree audit/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)”
  • Firewallsynvirt-firewall::FirewallService (nftables inet synvirt table) with get / set_enabled / add_rule / update_rule / delete_rule / move_rule / test, mounted under /api/v1/settings/firewall*. Every mutation already takes actor: &str and records to the shared AuditStore.
  • SSH keyscrates/daemon/src/settings/ssh_keys.rs::SshKeysService (AUTHORIZED_KEYS_PATH = /root/.ssh/authorized_keys) with list / add / remove by SHA-256 fingerprint, audited.
  • Servicessynvirt-services::allowlist PLATFORM array lists ssh.service, nftables.service, libvirtd.service; the Services panel can already enable/disable/start/stop allowlisted units. An “SSH on/off” toggle is ssh.service through this path — no new systemd plumbing.

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.”

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.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:power
vm:edit-hardware vm:console vm:snapshot vm:migrate
storage:read storage:write network:read network:write
settings:read settings:write security:read security:write
host:read host:reboot backup:read backup:write

A 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) -> Decision

Wired 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.

/// 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.

Extend SessionStore minimally:

  • carry the resolved PermissionSet (or role ids) on Session so per-request authz is a map lookup, not a sled read;
  • add list() -> Vec<SessionView> and use the existing revoke for force-logout;
  • make SESSION_TTL configurable (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.

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.

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/users
POST /api/v1/users create local user (+ Unix acct)
GET /api/v1/users/{username}
PATCH /api/v1/users/{username} display name, enabled, roles
DELETE /api/v1/users/{username} soft by default; ?hard=true typed-confirm
POST /api/v1/users/{username}/password set/reset (chpasswd)
POST /api/v1/users/{username}/mfa enroll (future seam)
# Roles
GET /api/v1/roles
POST /api/v1/roles custom role
GET /api/v1/roles/{id}
PUT /api/v1/roles/{id} edit permissions (builtin: perms editable, not deletable)
DELETE /api/v1/roles/{id} custom only
GET /api/v1/permissions the catalog (for the UI to render checkboxes)
# Sessions
GET /api/v1/sessions active sessions (principal, age, idle, source)
DELETE /api/v1/sessions/{token} force-logout one
DELETE /api/v1/sessions?user={u} force-logout all of a principal
GET /api/v1/sessions/policy idle-timeout etc.
PUT /api/v1/sessions/policy
# Audit
GET /api/v1/audit/log cursor-paginated AuditStore.list_recent, filterable by actor/outcome/endpoint
GET /api/v1/audit/policy enabled + retention
PUT /api/v1/audit/policy
# Hardening
GET/PUT /api/v1/settings/security/ssh (delegates to services + ssh_keys)
GET/PUT /api/v1/settings/security/lockdown
GET/PUT /api/v1/settings/security/lockout

New 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).


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), and GET /api/v1/audit/log (just expose the already-written AuditStore). 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 AuthBackend with an LdapBackend / KanidmBackend implementation; users gain source = Ldap. MFA TOTP verify step.

  1. 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_ADMIN guard, and the Console TUI escape hatch are non-negotiable.
  2. 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.
  3. Session durability. In-memory SessionStore means force-logout and the session list don’t survive a restart. Resolve in Phase 1 (mirror to sled) — flagged in §3.4.
  4. Audit store sharing. Reusing audit/settings vs a new audit/security tree on the same Db. Recommend a second tree to keep filtering clean; confirm the single-handle invariant is respected (open once in main.rs).
  5. root and 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. The E_LAST_ADMIN guard + TUI recovery are the safety net; confirm Tony wants root re-scopable at all, or pinned as an un-droppable super-admin.
  6. 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).
  7. MFA storage. TOTP secrets are sensitive; reuse synvirt-bmc::EncryptionKey (AES-256-GCM, already the canonical host key) rather than inventing a second keystore.

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.