Skip to content

RBAC

Role-based access control in Etminan is enforced by a process, not a policy file. Every trust-changing action on the verifier — enrol a host, approve a baseline, change the operator registry — is reached through a long-running privilege-separated daemon, etminan-verifierd, over a local Unix socket. The daemon is the only process that touches verifier state and the signing key, so the daemon is the wall: it can allow or deny an operator regardless of what that operator can type.

RBAC is part of the Standard build and is on by default. There is no feature flag to turn it on and no edition to buy: the daemon is how you operate the verifier.

Why a process boundary is required

A plain CLI can never enforce anything against a user who is allowed to run it. If the authorisation check and the signing key live inside the same binary the operator invokes, that operator can read the key, patch the check, or call the underlying code directly — the "gate" is decoration. Only a process boundary can enforce authority: a separate, differently-privileged process that holds the key, makes the decision, and hands back nothing but a result.

etminan-verifierd is that process. It runs as its own service (etminan-verifierd.service), owns the baseline database and the signing key, and listens on a Unix socket. Operators never touch either; they send a request and receive an allow or a deny.

Authentication — kernel peer-UID

When a client connects to the socket, the daemon reads the peer's credentials straight from the kernel via SO_PEERCRED. The connecting UID is supplied by the kernel, not by the client, so a non-root peer cannot forge it. That single fact is the whole authentication story:

  • No key files to steal, copy, or leave on a monitored host.
  • No PAM, no passwords, no shared secrets on the request path.

The operator's Unix identity is their credential. Log in as yourself and the daemon already knows who you are.

Authorisation — a default-deny role/scope matrix

Authenticated is not authorised. Each UID is mapped, in the daemon's identity registry, to exactly one role and a scope. The matrix is default-deny: a UID that is unmapped, or whose mapping was revoked, is refused every trust-changing action. There is no implicit fallback — fail-closed is the only behaviour.

Role May do Scope
admin Manages the identity registry — add, list, and revoke operator identities; every operational action Always unscoped (whole fleet)
operator Approves baselines and drives day-to-day trust changes within an assigned host-group Confined to one host-group
viewer Read-only — status, history, listings. Signs nothing Read view only

admin is the only role that changes who else is trusted; operator does the daily work but is boxed into its host-group; viewer is a pure read credential. Any UID not present in the registry — or present but revoked — is denied.

Host scope

An operator mapping carries a scope: a host-group the operator is confined to. Scope is checked after the role check, on host-targeted actions, so a region-A operator can approve region-A hosts and nothing else. admin identities are always unscoped and see the whole fleet.

The op client

Operators drive the daemon with the thin client etminan-verifier op <cmd>. It carries no key and holds no authority of its own — it just marshals your request onto the socket, where the daemon authenticates and authorises it.

etminan-verifier op whoami          # what the daemon thinks you are: uid, role, scope
etminan-verifier op ping            # liveness check against the socket

# Identity registry (admin only)
etminan-verifier op identity list
etminan-verifier op identity add --uid 1007 --role operator \
    --scope region-a --label "carol (region-A operator)"
etminan-verifier op identity revoke --uid 1007

# Day-to-day
etminan-verifier op approve web-01  # operator: approve a baseline in scope

Bootstrapping the first admin

Genesis is a chicken-and-egg problem: an empty registry has no admin to authorise the first one. So the first admin is installed once, as root, directly on the verifier host:

# Run as root, one time at verifier bring-up
etminan-verifier op bootstrap --uid 1001 --label "admin-1"

Requiring root for genesis means the bootstrap authority is the machine's own root, not a forgeable request. The only non-root path is the escape hatch ETMINAN_VERIFIERD_ALLOW_INSECURE_BOOTSTRAP, which is DEV/TEST only — never set it in production. From that first admin onward, all further identities are added through op identity add under the default-deny matrix.

Two host prerequisites (or nothing works)

Neither of these is a code step; both are one-time host setup that must hold, and both fail loudly if they don't.

1. The daemon-owned state files must be readable/writable by the daemon's user. etminan-verifierd runs as etminan-verifier (see etminan-verifierd.service). If a file it needs ends up owned by root — e.g. it was created by a root-run op bootstrap, or by starting the daemon by hand as root — the daemon fails. The two that bite:

  • daemon-signing.key — read at startup; a root-owned key crash-loops the daemon with reading daemon key … Permission denied (os error 13).
  • state.json — the pinned AK/EK/TLS fingerprint store, written by op enroll/op rotate-*; a root-owned one fails those with reading state file … Permission denied (os error 13). The routine op enroll creates it with the correct owner; a root-run bootstrap can leave it root-owned.

Ensure both are owned by the daemon user:

chown etminan-verifier:etminan-verifier /var/lib/etminan-verifier/daemon-signing.key \
      /var/lib/etminan-verifier/state.json
chmod 600 /var/lib/etminan-verifier/daemon-signing.key
systemctl restart etminan-verifierd

2. Nothing else has to be granted to reach the socket. Every local account can connect to /run/etminan-verifierd/etminan-verifierd.sock. There is no group to add anybody to, and adding one is not a way to grant access — the registry is.

That is deliberate. A role can come from a directory group (see Directory integration), Unix groups do not nest, and a group-gated socket refused exactly those people before the daemon was ever asked. What decides is the peer UID (SO_PEERCRED) checked against the identity registry, with default-deny, plus the role/scope matrix. An account with no role gets uid <n> is not a registered operator and nothing else — it cannot read baseline.db or the signing key, which stay 0600 owner-only.

Denials from an unregistered UID are audited, but at most once per minute per account; the next row states how many attempts it stands for. Somebody probing the socket still shows up on the chain, without being able to grow it at will.

The daemon holds the only key

Operators hold no signing key at all. The daemon holds the single Ed25519 key (/var/lib/etminan-verifier/daemon-signing.key) and, once it has authenticated and authorised the caller, signs the action on that operator's behalf. The signed record is attributed operator:<label>, so accountability is preserved without any key ever leaving the daemon or reaching an operator.

This is what makes the boundary meaningful: even an operator with a shell on the verifier cannot sign a decision the daemon would refuse, because they never possess the key that turns a request into a trusted record.

Every decision is on the audit chain

Allow and deny alike are appended to the hash-chained, externally-anchored audit log. A refused request is not silent — it is evidence. Because the chain is tamper-evident and its head is anchored outside the box, the record of who was denied what, when is as durable as the record of what was approved.

Optional TOTP two-factor

The daemon supports native TOTP (RFC 6238) as a second factor on top of peer-UID, off by default and configurable per role:

etminan-verifier op totp-policy --role operator --required true   # require TOTP for operators
etminan-verifier op enroll-totp                                   # prints an otpauth:// URI to scan
etminan-verifier op login --code 123456                           # open a session
etminan-verifier op logout                                        # end it early

A login opens a session with an 8-hour TTL (ETMINAN_TOTP_SESSION_TTL_SECS). No key files are involved — TOTP layers a possession factor onto the identity the kernel already proved.

The request flow

sequenceDiagram
    autonumber
    actor Op as Operator (op client, no key)
    participant Sock as Unix socket
    participant D as etminan-verifierd
    participant Reg as identity registry
    participant Log as audit_log (hash-chained)
    participant DB as baseline.db + signing key

    Op->>Sock: op approve web-01
    Sock->>D: connect (peer-UID via SO_PEERCRED)
    D->>D: kernel-supplied UID — unforgeable
    D->>Reg: UID → role + scope?
    alt mapped, role permits, host in scope, TOTP (if required) valid
        Reg-->>D: operator / region-a / ok
        D->>Log: append ALLOW
        D->>DB: sign on operator's behalf, apply
        D-->>Op: applied (attributed operator:carol)
    else unmapped / revoked / out of scope / no session
        Reg-->>D: denied (default-deny)
        D->>Log: append DENY
        D-->>Op: refused
    end

Four-eyes (dual control)

RBAC decides whether one operator may act; dual control decides whether one is enough. It is an Enterprise capability, off by default, and it rides entirely on the same identity model — no key files, no separate command.

Turn it on in either of two ways:

# Env floor (set on the verifier process): baseline-approve is the wired action
ETMINAN_DUAL_CONTROL_ACTIONS=baseline-approve

# Signed, latched policy (admin) — and setting it is itself four-eyes:
# a second admin must co-sign before it takes effect
etminan-verifier op dual-control-policy set --actions baseline-approve \
    --threshold 2 --reason "enforce four-eyes on approvals"
etminan-verifier op dual-control-policy show     # the effective policy

The effective policy is the union of the two: the env may only add coverage, never weaken the signed latch — so an operator with shell access on the verifier cannot disable four-eyes just by editing an environment variable. ETMINAN_DUAL_CONTROL_THRESHOLD (default 2) sets how many distinct operators must agree; an unknown action token is a hard error, never a silent single-signed bypass.

When four-eyes is in force, a baseline approval is not applied on one operator's action. The daemon records it as a pending request and applies it only once threshold distinct authenticated operators have co-signed — each simply by running their own op approve <host> under their own kernel UID. The daemon mediates the co-signing: a co-signer whose UID or identity label matches a prior approver's is refused, so one person cannot cast both votes. Every co-signer is checked against the same default-deny role/scope matrix. Four-eyes requires the identity registry to be bootstrapped first (op bootstrap + op identity add); ETMINAN_DUAL_CONTROL_TTL_HOURS bounds how long an open request stays pending (default 168).

Where to go next

  • The audit log — where every allow and deny is recorded and anchored.
  • Editions — what is Standard vs Enterprise.