# MCIAS Architecture

Metacircular Identity and Access System — Technical Design Document

---

## 1. System Overview

MCIAS is a self-hosted SSO and IAM service for a single developer's personal
applications. It is deliberately small-scope: no federation, no multi-tenant
complexity, no external IdP delegation. The security model is simple but
rigorous: all trust flows from the MCIAS server; applications are relying
parties that delegate authentication decisions to it.

### Components

```
┌─────────────────────────────────────────────────────────┐
│                    MCIAS Server (mciassrv)              │
│  ┌──────────┐  ┌──────────┐  ┌───────────────────┐      │
│  │  Auth    │  │  Token   │  │  Account / Role   │      │
│  │  Handler │  │  Manager │  │  Manager          │      │
│  └────┬─────┘  └────┬─────┘  └─────────┬─────────┘      │
│       └─────────────┴─────────────────┘                 │
│                        │                                │
│              ┌─────────▼──────────┐                     │
│              │   SQLite Database   │                    │
│              └────────────────────┘                     │
│                                                         │
│   ┌──────────────────┐   ┌──────────────────────┐       │
│   │  REST listener   │   │  gRPC listener       │       │
│   │  (net/http)      │   │  (google.golang.org/ │       │
│   │  :8443           │   │   grpc) :9443        │       │
│   └──────────────────┘   └──────────────────────┘       │
└─────────────────────────────────────────────────────────┘
     ▲             ▲             ▲              ▲
     │ HTTPS/REST  │ HTTPS/REST  │ gRPC/TLS     │ direct file I/O
     │             │             │              │
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
│ Personal  │ │ mciasctl │ │ mciasgrpcctl │ │  mciasdb   │
│   Apps    │ │  (admin  │ │ (gRPC admin  │ │  (DB tool) │
└───────────┘ │   CLI)   │ │     CLI)     │ └────────────┘
              └──────────┘ └──────────────┘
```

**mciassrv** — The authentication server. Exposes a REST API and gRPC API over
HTTPS/TLS (dual-stack; see §17). Handles login, token issuance, token
validation, token renewal, and token revocation.

**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
using an admin JWT. Creates/manages human accounts, system accounts, roles,
and Postgres credential records.

**mciasgrpcctl** — The gRPC administrator CLI. Mirrors mciasctl's subcommands
but communicates over gRPC/TLS instead of REST. Both CLIs can coexist; neither
depends on the other.

**mciasdb** — The database maintenance tool. Operates directly on the SQLite
file, bypassing the server API. Intended for break-glass recovery, offline
inspection, schema verification, and maintenance tasks that cannot be
performed through the live server. Requires the same master key material as
mciassrv (passphrase or keyfile) to decrypt secrets at rest.

---

## 2. Security Model

### Threat Model

- **Attacker capabilities assumed:** Network interception (mitigated by TLS),
  credential guessing (mitigated by Argon2id, account lockout), stolen JWT
  (mitigated by short expiry + revocation), stolen DB file (mitigated by
  hashed/encrypted credentials at rest).
- **Out of scope:** Physical access to the server host, OS-level compromise,
  supply-chain attacks on Go dependencies.
- **Trust boundary:** The MCIAS server is the single root of trust. Applications
  must not make authorization decisions without first validating a JWT from
  MCIAS. All signing keys live exclusively on the MCIAS server.

### Key Principles

1. **Defense in depth.** Passwords are hashed with Argon2id; JWTs are signed
   with Ed25519; all transport uses TLS 1.2+ (TLS 1.3 preferred).
2. **Least privilege.** System accounts have no interactive login path. Human
   accounts have only the roles explicitly granted. Admin operations require
   the `admin` role.
3. **Fail closed.** Invalid, expired, or unrecognized tokens must be rejected
   immediately. Missing claims are not assumed; they are treated as invalid.
4. **No credential leakage.** Passwords, raw tokens, and private keys must
   never appear in logs, error messages, API responses, or stack traces.
5. **Constant-time comparisons.** All equality checks on secret material
   (tokens, password hashes, TOTP codes) use `crypto/subtle.ConstantTimeCompare`
   to prevent timing side-channels.

---

## 3. Cryptographic Primitives

| Purpose | Algorithm | Rationale |
|---|---|---|
| Password hashing | Argon2id | OWASP-recommended; memory-hard; resists GPU/ASIC attacks. Parameters: time=3, memory=64MB, threads=4 (meets OWASP 2023 minimum of time=2, memory=64MB). Master key derivation uses time=3, memory=128MB, threads=4 (higher cost acceptable at startup). |
| JWT signing | Ed25519 (EdDSA) | Fast, short signatures, no parameter malleability, immune to invalid-curve attacks. RFC 8037. |
| JWT key storage | Raw Ed25519 private key in PEM-encoded PKCS#8 file, chmod 0600. | |
| TOTP | HMAC-SHA1 per RFC 6238 (industry standard). Shared secret stored encrypted with AES-256-GCM using a server-side key. | |
| Credential storage | AES-256-GCM with a server-side master key. | |
| Random values | `crypto/rand` exclusively. Never `math/rand`. | |

### JWT Security Rules (non-negotiable)

- Algorithm in header **must** be `EdDSA`. Any other value (including `none`,
  `HS256`, `RS256`, `ES256`) must cause immediate rejection before any
  signature verification is attempted.
- The public key used to verify a JWT is taken from the server's keystore,
  never from the token itself.
- All standard claims are validated: `exp` (required, enforced), `iat`
  (required), `nbf` (optional but enforced if present), `iss` (must match
  configured issuer), `jti` (required; checked against revocation list).
- Tokens are opaque to relying-party apps; they validate tokens by calling
  the MCIAS `/v1/token/validate` endpoint (or, for trusted apps, by verifying
  the Ed25519 signature against the published public key).

---

## 4. Account Model

### Account Types

**Human accounts** — interactive users. Can authenticate via:
- Username + password (Argon2id hash stored in DB)
- Optional TOTP (RFC 6238); if enrolled, required on every login
- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
  enable passwordless login, non-discoverable credentials serve as 2FA

**System accounts** — non-interactive service identities. Have:
- A single active bearer token at a time (rotating the token revokes the old one)
- No password, no TOTP
- An associated Postgres credential record (optional)

### Roles

Roles are simple string labels stored in the `account_roles` table. Only
compile-time allowlisted role names are accepted; attempting to grant an
unknown role returns an error (prevents typos like "admim" from silently
creating a useless role).

Compile-time allowlisted roles:
- `admin` — superuser; can manage all accounts, tokens, and credentials
- `user` — standard user role
- `guest` — limited read-only access
- `viewer` — read-only access
- `editor` — create/modify access
- `commenter` — comment/annotate access
- Any role named identically to a system account — grants that human account
  the ability to issue/revoke tokens and retrieve Postgres credentials for that
  system account (via policy rules, not the allowlist)

Role assignment requires admin privileges.

### Tags

Accounts (both human and system) may carry zero or more string tags stored in
the `account_tags` table. Tags are used by the policy engine to match resource
access rules against machine or service identity.

Tag naming convention (not enforced by the schema, but recommended):
- `env:production`, `env:staging` — environment tier
- `svc:payments-api` — named service association
- `machine:db-west-01` — specific host label

Tag management requires admin privileges.

### Account Lifecycle

```
Human:  [created by admin] → active → [password change] → active
                                    → [TOTP enroll]    → active (TOTP required)
                                    → [suspended]      → inactive
                                    → [deleted]        → soft-deleted, tokens revoked

System: [created by admin] → active → [token rotated]  → active (old token revoked)
                                    → [deleted]        → soft-deleted, token revoked
```

---

## 5. Token Lifecycle

### Token Types

| Type | Subject | Expiry (default) | Renewable | Revocable |
|---|---|---|---|---|
| Session JWT | human user | 30 days | yes | yes |
| Service token | system account | 365 days | yes (rotate) | yes |
| Admin JWT | human user (admin role) | 8 hours | yes | yes |

### Issuance Flow — Human Login

```
Client                   mciassrv
  │                          │
  ├─ POST /v1/auth/login ───▶│
  │  {username, password,    │
  │   totp_code (opt)}       │
  │                          ├─ 1. Load account record; verify status=active
  │                          ├─ 2. Argon2id verify(password, stored_hash)
  │                          │      → constant-time; failed → 401, log event
  │                          ├─ 3. If TOTP enrolled: verify TOTP code
  │                          │      → constant-time; failed → 401, log event
  │                          ├─ 4. Generate JWT:
  │                          │      header: {"alg":"EdDSA","typ":"JWT"}
  │                          │      claims: {iss, sub (user UUID), iat, exp,
  │                          │               jti (UUID), roles:[...]}
  │                          ├─ 5. Sign with Ed25519 private key
  │                          ├─ 6. Store jti + exp in token_revocation table
  │                          ├─ 7. Log audit event (login_ok, user, IP)
  │◀─ 200 {token, expires_at}│
```

### Token Validation Flow

```
Client App               mciassrv
  │                          │
  ├─ POST /v1/token/validate▶│
  │  Authorization: Bearer   │
  │                          ├─ 1. Parse JWT; extract alg header
  │                          │      → if alg != "EdDSA": reject 401
  │                          ├─ 2. Verify Ed25519 signature
  │                          ├─ 3. Validate claims: exp, iat, iss, jti
  │                          ├─ 4. Check jti against revocation table
  │                          │      → if revoked: reject 401
  │                          ├─ 5. Return {valid: true, sub, roles, exp}
  │◀─ 200 {valid, sub, roles}│
```

### Token Renewal

A valid, non-expired, non-revoked token may be exchanged for a new token with
a fresh expiry window. The old token's `jti` is added to the revocation table
(marked revoked) upon successful renewal.

### Token Revocation

Revoked tokens are stored in the `token_revocation` table with their `jti`
and original `exp`. A background task (or on-demand sweep) removes rows whose
`exp` is in the past, since expired tokens are inherently invalid.

Admin users can revoke any token. Users with the role matching a system account
can revoke that system account's token. Human users can revoke their own tokens
(logout).

---

## 6. Session Management

MCIAS is stateless at the HTTP level — there are no server-side sessions.
"Session state" is encoded in the JWT itself (roles, user ID, expiry). The
revocation table provides the statefulness needed for logout and forced
invalidation.

Key properties:
- Concurrent logins are permitted (multiple live JTIs per user)
- Logout revokes only the presented token (single-device logout)
- Admin can revoke all tokens for a user (e.g., on account suspension)
- Token expiry is enforced at validation time, regardless of revocation table

### Password Change Flows

Two distinct flows exist for changing a password, with different trust assumptions:

#### Self-Service Password Change (`PUT /v1/auth/password`)

Used by a human account holder to change their own password.

1. Caller presents a valid JWT and supplies both `current_password` and
   `new_password` in the request body.
2. The server looks up the account by the JWT subject.
3. **Lockout check** — same policy as login (10 failures in 15 min → 15 min
   lockout). An attacker with a stolen token cannot use this endpoint to
   brute-force the current password without hitting the lockout.
4. **Current password verified** with `auth.VerifyPassword` (Argon2id,
   constant-time via `crypto/subtle.ConstantTimeCompare`). On failure a login
   failure is recorded and HTTP 401 is returned.
5. New password is validated (minimum 12 characters) and hashed with Argon2id
   using the server's configured parameters.
6. The new hash is written atomically to the `accounts` table.
7. **All tokens except the caller's current JTI are revoked** (reason:
   `password_changed`). The caller keeps their active session; all other
   concurrent sessions are invalidated. This limits the blast radius of a
   credential compromise without logging the user out mid-operation.
8. Login failure counter is cleared (successful proof of knowledge).
9. Audit event `password_changed` is written with `{"via":"self_service"}`.

#### Admin Password Reset (`PUT /v1/accounts/{id}/password`)

Used by an administrator to reset a human account's password for recovery
purposes (e.g. user forgot their password, account handover).

1. Caller presents an admin JWT.
2. Only `new_password` is required; no `current_password` verification is
   performed. The admin role represents a higher trust level.
3. New password is validated (minimum 12 characters) and hashed with Argon2id.
4. The new hash is written to the `accounts` table.
5. **All active tokens for the target account are revoked** (reason:
   `password_reset`). Unlike the self-service flow, the admin cannot preserve
   the user's session because the reset is typically done during an outage of
   the user's access.
6. Audit event `password_changed` is written with `{"via":"admin_reset"}`.

#### Security Notes

- The current password requirement on the self-service path prevents an
  attacker who steals a JWT from changing credentials. A stolen token grants
  access to resources for its remaining lifetime but cannot be used to
  permanently take over the account.
- Admin resets are always audited with both actor and target IDs so the log
  shows which admin performed the reset.
- Plaintext passwords are never logged, stored, or included in any response.
- Both flows use the same Argon2id parameters (OWASP 2023: time=3, memory=64 MB,
  threads=4, hash length=32 bytes).

---

## 7. Multi-App Trust Boundaries

Each personal application that relies on MCIAS for authentication is a
**relying party**. Trust boundaries:

1. **MCIAS is the sole issuer.** Apps must not issue their own identity tokens.
2. **Apps validate tokens via MCIAS.** Either by calling `/v1/token/validate`
   (recommended; gets revocation checking) or by verifying the Ed25519
   signature against the published public key (skips revocation check).
3. **Role-based access.** Apps use the `roles` claim in the validated JWT to
   make authorization decisions. MCIAS does not know about app-specific
   permissions; it only knows about global roles.
4. **Audience scoping (future).** In v1 tokens are not audience-scoped. A
   future `aud` claim may restrict tokens to specific apps.
5. **Service accounts per app.** Each personal app should have a corresponding
   system account. The app may authenticate to MCIAS using its service token
   to call protected management endpoints.

---

## 8. API Design

Base path: `/v1`

All endpoints use JSON request/response bodies. All responses include a
`Content-Type: application/json` header. Errors follow a uniform structure:

```json
{"error": "human-readable message", "code": "machine_readable_code"}
```

### Authentication Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |

### Token Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/token/validate` | none | Validate a JWT (passed as Bearer header) |
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |

### Token Download Endpoint

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/token/download/{nonce}` | bearer JWT | Download a previously issued token via one-time nonce (5-min TTL, single-use) |

The token download flow issues a short-lived nonce when a service token is created
via `POST /accounts/{id}/token`. The bearer must be authenticated; the nonce is
deleted on first download to prevent replay. This avoids exposing the raw token
value in an HTMX fragment or flash message.

### Token Delegation Endpoints (admin only)

| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/accounts/{id}/token/delegates` | admin JWT | Grant a human account permission to issue tokens for a system account |
| DELETE | `/accounts/{id}/token/delegates/{grantee}` | admin JWT | Revoke token-issue delegation |

### Account Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/accounts` | admin JWT | List all accounts |
| POST | `/v1/accounts` | admin JWT | Create human or system account |
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
| POST | `/v1/accounts/{id}/token` | bearer JWT (admin or delegate) | Issue/rotate service account token |

### Password Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| PUT | `/v1/auth/password` | bearer JWT | Self-service: change own password (current password required) |
| PUT | `/v1/accounts/{id}/password` | admin JWT | Admin reset: set any human account's password |

### Role Endpoints (admin only)

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account |
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set (atomic) |
| POST | `/v1/accounts/{id}/roles` | admin JWT | Grant a single role |
| DELETE | `/v1/accounts/{id}/roles/{role}` | admin JWT | Revoke a single role |

### TOTP Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/totp/enroll` | bearer JWT | Begin TOTP enrollment (returns secret + QR URI) |
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |

### WebAuthn Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |

### Postgres Credential Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/pgcreds` | bearer JWT | List all credentials accessible to the caller (owned + explicitly granted) |
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials for a specific account |
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |

### Tag Endpoints (admin only)

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/accounts/{id}/tags` | admin JWT | List tags for account |
| PUT | `/v1/accounts/{id}/tags` | admin JWT | Replace tag set for account |

### Policy Endpoints (admin only)

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/policy/rules` | admin JWT | List all policy rules |
| POST | `/v1/policy/rules` | admin JWT | Create a new policy rule |
| GET | `/v1/policy/rules/{id}` | admin JWT | Get a single policy rule |
| PATCH | `/v1/policy/rules/{id}` | admin JWT | Update rule (priority, enabled, description) |
| DELETE | `/v1/policy/rules/{id}` | admin JWT | Delete a policy rule |

### Audit Endpoints (admin only)

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/audit` | admin JWT | List audit log events |

### Vault Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/vault/status` | none | Returns `{"sealed": bool}`; always accessible |
| POST | `/v1/vault/unseal` | none | Accept passphrase, derive key, unseal (rate-limited 3/s burst 5) |
| POST | `/v1/vault/seal` | admin JWT | Zero key material and seal the vault; invalidates all JWTs |

When the vault is sealed, all endpoints except health, vault status, and unseal
return 503 with `{"error":"vault is sealed","code":"vault_sealed"}`. The UI
redirects non-exempt paths to `/unseal`.

### Admin / Server Endpoints

| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/health` | none | Health check — returns `{"status":"ok"}` or `{"status":"sealed"}` |
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |

### Web Management UI

mciassrv embeds an HTMX-based web management interface served alongside the
REST API. The UI is an admin-only interface providing a visual alternative to
`mciasctl` for day-to-day management.

**Package:** `internal/ui/` — UI handlers call internal Go functions directly;
no internal HTTP round-trips to the REST API.

**Template engine:** Go `html/template` with templates embedded at compile time
via `web/` (`embed.FS`). Templates are parsed once at startup.

**Session management:** JWT stored as `HttpOnly; Secure; SameSite=Strict`
cookie (`mcias_session`). CSRF protection uses HMAC-signed double-submit
cookie pattern (`mcias_csrf`).

**Pages and features:**

| Path | Description |
|---|---|
| `/unseal` | Passphrase form to unseal the vault; shown for all paths when sealed |
| `/login` | Username/password login with optional TOTP step |
| `/` | Dashboard (account summary) |
| `/accounts` | Account list |
| `/accounts/{id}` | Account detail — status, roles, tags, PG credentials (system accounts) |
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
| `/policies` | Policy rules management — create, enable/disable, delete |
| `/audit` | Audit log viewer |
| `/profile` | User profile — self-service password change (any authenticated user) |
| `/service-accounts` | Delegated service account list for non-admin users; issue/rotate token with one-time download |

**HTMX fragments:** Mutating operations (role updates, tag edits, credential
saves, policy toggles, access grants) use HTMX partial-page updates for a
responsive experience without full-page reloads.

---

## 9. Database Schema

Database: SQLite 3, WAL mode enabled, `PRAGMA foreign_keys = ON`.

All tables use `INTEGER PRIMARY KEY` surrogate keys (SQLite rowid alias).
UUIDs used for external identifiers (stored as TEXT).

```sql
-- Server-side secrets (one row always)
CREATE TABLE server_config (
    id                  INTEGER PRIMARY KEY CHECK (id = 1),
    -- Ed25519 private key, PEM PKCS#8, encrypted at rest with AES-256-GCM
    -- using a master key derived from the startup passphrase.
    signing_key_enc     BLOB,
    signing_key_nonce   BLOB,
    -- Argon2id salt for master key derivation; stable across restarts so the
    -- passphrase always yields the same key. Generated on first run.
    master_key_salt     BLOB,
    created_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    updated_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

-- Human and system accounts
CREATE TABLE accounts (
    id                  INTEGER PRIMARY KEY,
    uuid                TEXT    NOT NULL UNIQUE,
    username            TEXT    NOT NULL UNIQUE COLLATE NOCASE,
    account_type        TEXT    NOT NULL CHECK (account_type IN ('human','system')),
    -- NULL for system accounts; PHC-format Argon2id string for human accounts
    password_hash       TEXT,
    status              TEXT    NOT NULL DEFAULT 'active'
                                CHECK (status IN ('active','inactive','deleted')),
    -- 1 if TOTP is enrolled and required; human accounts only
    totp_required       INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
    -- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
    totp_secret_enc     BLOB,
    totp_secret_nonce   BLOB,
    -- Last accepted TOTP counter value; prevents replay attacks within the
    -- ±1 time-step window (RFC 6238 §5.2).  NULL = no code accepted yet.
    last_totp_counter   INTEGER DEFAULT NULL,
    created_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    updated_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    deleted_at          TEXT
);

CREATE INDEX idx_accounts_username ON accounts (username);
CREATE INDEX idx_accounts_uuid     ON accounts (uuid);
CREATE INDEX idx_accounts_status   ON accounts (status);

-- Role assignments
CREATE TABLE account_roles (
    id          INTEGER PRIMARY KEY,
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    role        TEXT    NOT NULL,
    granted_by  INTEGER REFERENCES accounts(id),
    granted_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    UNIQUE (account_id, role)
);

CREATE INDEX idx_account_roles_account ON account_roles (account_id);

-- Token tracking table. Tracks all issued tokens by JTI for revocation.
-- Rows where both revoked_at IS NULL and expires_at is in the future represent
-- currently-valid tokens. Rows are pruned when expires_at < now.
-- The token value itself is NEVER stored here.
CREATE TABLE token_revocation (
    id            INTEGER PRIMARY KEY,
    jti           TEXT    NOT NULL UNIQUE,
    account_id    INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    expires_at    TEXT    NOT NULL,
    revoked_at    TEXT,
    revoke_reason TEXT,
    issued_at     TEXT    NOT NULL,
    created_at    TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

CREATE INDEX idx_token_jti     ON token_revocation (jti);
CREATE INDEX idx_token_account ON token_revocation (account_id);
CREATE INDEX idx_token_expires ON token_revocation (expires_at);

-- Current active service token for each system account (one per account).
-- When rotated, the old JTI is marked revoked in token_revocation.
CREATE TABLE system_tokens (
    id          INTEGER PRIMARY KEY,
    account_id  INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
    jti         TEXT    NOT NULL UNIQUE,
    expires_at  TEXT    NOT NULL,
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

-- Per-account failed login attempts for brute-force lockout enforcement.
-- One row per account; window_start resets when the window expires or on
-- a successful login.
CREATE TABLE failed_logins (
    account_id    INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
    window_start  TEXT    NOT NULL,
    attempt_count INTEGER NOT NULL DEFAULT 1
);

-- Postgres credentials for system accounts, encrypted at rest.
CREATE TABLE pg_credentials (
    id                  INTEGER PRIMARY KEY,
    account_id          INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
    -- owner_id: account that administers the credentials and may grant/revoke
    -- access.  Nullable for backwards compatibility with pre-migration-5 rows.
    owner_id            INTEGER REFERENCES accounts(id),
    pg_host             TEXT    NOT NULL,
    pg_port             INTEGER NOT NULL DEFAULT 5432,
    pg_database         TEXT    NOT NULL,
    pg_username         TEXT    NOT NULL,
    pg_password_enc     BLOB    NOT NULL,
    pg_password_nonce   BLOB    NOT NULL,
    created_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    updated_at          TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

-- Explicit read-access grants from a credential owner to another account.
-- Grantees may view connection metadata but the password is never decrypted
-- for them in the UI.  Only the owner may update or delete the credential set.
CREATE TABLE pg_credential_access (
    id              INTEGER PRIMARY KEY,
    credential_id   INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
    grantee_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    granted_by      INTEGER REFERENCES accounts(id),
    granted_at      TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    UNIQUE (credential_id, grantee_id)
);

CREATE INDEX idx_pgcred_access_cred    ON pg_credential_access (credential_id);
CREATE INDEX idx_pgcred_access_grantee ON pg_credential_access (grantee_id);

-- Audit log — append-only. Never contains credentials or secret material.
CREATE TABLE audit_log (
    id          INTEGER PRIMARY KEY,
    event_time  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    event_type  TEXT    NOT NULL,
    actor_id    INTEGER REFERENCES accounts(id),
    target_id   INTEGER REFERENCES accounts(id),
    ip_address  TEXT,
    details     TEXT    -- JSON blob; never contains secrets
);

CREATE INDEX idx_audit_time  ON audit_log (event_time);
CREATE INDEX idx_audit_actor ON audit_log (actor_id);
CREATE INDEX idx_audit_event ON audit_log (event_type);

-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine for resource gating (e.g. env:production, svc:payments-api).
CREATE TABLE account_tags (
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    tag         TEXT    NOT NULL,
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    PRIMARY KEY (account_id, tag)
);

CREATE INDEX idx_account_tags_account ON account_tags (account_id);

-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
CREATE TABLE policy_rules (
    id          INTEGER PRIMARY KEY,
    priority    INTEGER NOT NULL DEFAULT 100,   -- lower value = evaluated first
    description TEXT    NOT NULL,
    rule_json   TEXT    NOT NULL,
    enabled     INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
    created_by  INTEGER REFERENCES accounts(id),
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    updated_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    not_before  TEXT    DEFAULT NULL,  -- optional: earliest activation time (RFC3339)
    expires_at  TEXT    DEFAULT NULL   -- optional: expiry time (RFC3339)
);

-- Token issuance delegation: tracks which human accounts may issue tokens for
-- a given system account without holding the global admin role. Admins manage
-- delegates; delegates can issue/rotate tokens for the specific system account
-- only and cannot modify any other account settings.
CREATE TABLE service_account_delegates (
    id          INTEGER PRIMARY KEY,
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,  -- target system account
    grantee_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,  -- human account granted access
    granted_by  INTEGER REFERENCES accounts(id),                             -- admin who granted access
    granted_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    UNIQUE (account_id, grantee_id)
);

CREATE INDEX idx_sa_delegates_account ON service_account_delegates (account_id);
CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
```

```sql
-- WebAuthn credentials (migration 000009)
CREATE TABLE webauthn_credentials (
    id                  INTEGER PRIMARY KEY,
    account_id          INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    name                TEXT    NOT NULL DEFAULT '',
    credential_id_enc   BLOB    NOT NULL,
    credential_id_nonce BLOB    NOT NULL,
    public_key_enc      BLOB    NOT NULL,
    public_key_nonce    BLOB    NOT NULL,
    aaguid              TEXT    NOT NULL DEFAULT '',
    sign_count          INTEGER NOT NULL DEFAULT 0,
    discoverable        INTEGER NOT NULL DEFAULT 0,
    transports          TEXT    NOT NULL DEFAULT '',
    created_at          TEXT    NOT NULL,
    updated_at          TEXT    NOT NULL,
    last_used_at        TEXT
);
CREATE INDEX idx_webauthn_credentials_account ON webauthn_credentials(account_id);
```

### Schema Notes

- Passwords are stored as PHC-format Argon2id strings (e.g.,
  `$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
  parameters. Future parameter upgrades are transparent.
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
  encrypted with AES-256-GCM using a master key held only in server memory
  (derived at startup from a passphrase or keyfile). The nonce is stored
  adjacent to the ciphertext.
- The master key salt is stored in `server_config.master_key_salt` so the
  Argon2id KDF produces the same key on every restart. Generated on first run.
- The signing key encryption is layered: the Ed25519 private key is wrapped
  with AES-256-GCM using the startup master key. Operators must supply the
  passphrase/keyfile on each server restart.
- The audit log is append-only and must never be pruned without explicit
  operator action.

---

## 10. TLS Configuration

mciassrv requires TLS. Configuration:
- Minimum version: TLS 1.2 (TLS 1.3 preferred)
- Certificate: operator-supplied PEM (path in config file)
- Cipher suites (TLS 1.2 only): ECDHE+AESGCM, ECDHE+CHACHA20
- Development/testing: self-signed cert acceptable; production must use a
  CA-signed cert (Let's Encrypt recommended)

---

## 11. Configuration

The server is configured via a TOML config file. Sensitive values (master key
passphrase) may be supplied via environment variable (`MCIAS_MASTER_PASSPHRASE`)
or a keyfile path — never inline in the config file.

```toml
[server]
listen_addr   = "0.0.0.0:8443"
grpc_addr     = "0.0.0.0:9443"   # optional; omit to disable gRPC
tls_cert      = "/etc/mcias/server.crt"
tls_key       = "/etc/mcias/server.key"
# trusted_proxy = "127.0.0.1"   # optional; IP of reverse proxy — when set,
                                 # X-Forwarded-For is trusted only from this IP
                                 # for rate limiting and audit log IP extraction

[database]
path = "/var/lib/mcias/mcias.db"

[tokens]
issuer          = "https://auth.example.com"
default_expiry  = "168h"    # 7 days
admin_expiry    = "8h"
service_expiry  = "8760h"   # 365 days

[argon2]
time    = 3
memory  = 65536   # KiB (64 MB)
threads = 4

[master_key]
# Exactly one of: passphrase_env or keyfile
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
```

---

## 12. Directory / Package Structure

```
mcias/
├── cmd/
│   ├── mciassrv/       # server binary entrypoint (REST + gRPC dual-stack)
│   │   └── main.go
│   ├── mciasctl/       # REST admin CLI
│   │   └── main.go
│   ├── mciasdb/        # direct SQLite maintenance tool
│   │   └── main.go
│   └── mciasgrpcctl/   # gRPC admin CLI companion
│       └── main.go
├── internal/
│   ├── audit/          # audit log event detail marshaling
│   ├── auth/           # login flow, TOTP verification, account lockout
│   ├── config/         # config file parsing and validation
│   ├── crypto/         # key management, AES-GCM helpers, master key derivation
│   ├── db/             # SQLite access layer (schema, migrations, queries)
│   │   └── migrations/ # numbered SQL migrations (currently 9)
│   ├── grpcserver/     # gRPC handler implementations
│   ├── middleware/     # HTTP middleware (auth extraction, logging, rate-limit, policy)
│   ├── model/          # shared data types (Account, Token, Role, PolicyRule, etc.)
│   ├── policy/         # in-process authorization policy engine (§20)
│   ├── server/         # HTTP handlers, router setup
│   ├── token/          # JWT issuance, validation, revocation
│   ├── ui/             # web UI context, CSRF, session, template handlers
│   ├── validate/       # input validation helpers (username, password strength)
│   ├── vault/          # master key lifecycle: seal/unseal state, key derivation
│   └── webauthn/       # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
├── web/
│   ├── static/         # CSS, JS, and bundled swagger-ui assets (embedded at build)
│   ├── templates/      # HTML templates (base layout, pages, HTMX fragments)
│   └── embed.go        # fs.FS embedding of static files and templates
├── proto/
│   └── mcias/v1/       # Protobuf service definitions
├── gen/
│   └── mcias/v1/       # Generated Go stubs from protoc (committed)
├── clients/
│   ├── go/             # Go client library
│   ├── python/         # Python client library
│   ├── rust/           # Rust client library
│   └── lisp/           # Common Lisp client library
├── test/
│   ├── e2e/            # end-to-end test suite
│   └── mock/           # Go mock server for client integration tests
├── dist/               # operational artifacts: systemd unit, install script, config templates
├── man/man1/           # man pages (mciassrv.1, mciasctl.1, mciasdb.1, mciasgrpcctl.1)
└── go.mod
```

All implementation packages are under `internal/` to prevent external import.
The `cmd/` packages are thin wrappers that wire dependencies and call into
`internal/`.

---

## 13. Error Handling and Logging

- All errors are wrapped with `fmt.Errorf("context: %w", err)`.
- Structured logging uses `log/slog` (or goutils wrapper).
- Log levels: DEBUG (dev only), INFO (normal ops), WARN (recoverable), ERROR
  (unexpected failures).
- Authentication events (success and failure) are always logged at INFO with:
  `{event, username (not password), ip, user_agent, timestamp, result}`.
- **Never log:** passwords, raw tokens, TOTP codes, master key material,
  Postgres credentials.

---

## 14. Audit Events

| Event type | Trigger |
|---|---|
| `login_ok` | Successful login |
| `login_fail` | Failed login (wrong password, unknown user) |
| `login_totp_fail` | Correct password, wrong TOTP code |
| `token_issued` | JWT issued (login or service token) |
| `token_renewed` | Token exchanged for a fresh one |
| `token_revoked` | Token explicitly revoked |
| `token_expired` | Attempt to use an expired token (at validation time) |
| `account_created` | New account created |
| `account_updated` | Account modified (status, roles) |
| `account_deleted` | Account soft-deleted |
| `role_granted` | Role assigned to account |
| `role_revoked` | Role removed from account |
| `totp_enrolled` | TOTP enrollment completed |
| `totp_removed` | TOTP removed from account |
| `pgcred_accessed` | Postgres credentials retrieved |
| `pgcred_updated` | Postgres credentials stored/updated |
| `pgcred_access_granted` | Read access to PG credentials granted to another account |
| `pgcred_access_revoked` | Read access to PG credentials revoked from an account |
| `password_changed` | Account password changed (self-service or admin reset) |
| `tag_added` | Tag added to account |
| `tag_removed` | Tag removed from account |
| `policy_rule_created` | Policy rule created |
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
| `policy_rule_deleted` | Policy rule deleted |
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
| `token_delegate_granted` | Admin granted a human account permission to issue tokens for a system account |
| `token_delegate_revoked` | Admin revoked a human account's token-issue delegation |
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |

---

## 15. Operational Considerations

- **Backups:** Use SQLite's online backup API or filesystem snapshot with WAL
  checkpointing. The master key/passphrase must be backed up separately and
  securely.
- **Key rotation:** Rotating the Ed25519 signing key requires re-issuing tokens
  for all users (old tokens become unverifiable). A dual-key grace period is
  not in v1 scope.
- **Rate limiting:** Login endpoints are rate-limited by IP (token bucket: 10
  attempts/minute). Implemented in middleware. In v1, an in-memory rate limiter
  is acceptable (single-instance deployment).
- **Master key loss:** Loss of the master key means all encrypted secrets
  (TOTP, Postgres passwords, signing key) are unrecoverable. Operators must
  back up the passphrase/keyfile securely.

---

## 16. mciasdb — Database Maintenance Tool

### Rationale

`mciasctl` is an API client: it requires a running mciassrv, a valid admin
JWT, and network access. This is appropriate for normal administration but
rules it out for several important scenarios:

- The server is down and accounts need to be inspected or repaired.
- Bootstrap: creating the first admin account before any JWT can exist.
- Offline forensics: reading the audit log without starting the server.
- Maintenance: pruning expired token rows, verifying schema integrity.
- Recovery: resetting a locked-out admin password when no other admin exists.

Adding direct DB access to `mciasctl` would blur the API-client / DB-operator
trust boundary and create pressure to use the bypass path for routine tasks.
A separate binary (`mciasdb`) makes the distinction explicit: it is a
break-glass tool that requires local filesystem access to the SQLite file and
the master key, and should only be used when the API is unavailable or
insufficient.

### Trust Model

`mciasdb` is a privileged, local-only tool. It assumes:

- The operator has filesystem access to the SQLite database file.
- The operator has the master key (passphrase env var or keyfile), same as
  mciassrv.
- No network connection is required or used.
- Audit events written by mciasdb are tagged with actor `mciasdb` (no UUID)
  so they are distinguishable from API-driven events in the audit log.

### Configuration

`mciasdb` accepts a subset of the mciassrv config file (the `[database]` and
`[master_key]` sections) via `--config` flag, identical in format to
mciassrv's config. This avoids a separate config format and ensures key
derivation is identical.

### Command Surface

```
mciasdb --config PATH <subcommand> [flags]
```

**Schema / maintenance:**

| Command | Description |
|---|---|
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
| `mciasdb schema migrate` | Apply any pending migrations and exit |
| `mciasdb schema force --version N` | Force schema version (clears dirty state); break-glass recovery |
| `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` |

**Account management (offline):**

| Command | Description |
|---|---|
| `mciasdb account list` | Print all accounts (uuid, username, type, status) |
| `mciasdb account get --id UUID` | Print single account record |
| `mciasdb account create --username NAME --type human\|system` | Insert account row directly |
| `mciasdb account set-password --id UUID` | Prompt for new password, re-hash with Argon2id, update row |
| `mciasdb account set-status --id UUID --status active\|inactive\|deleted` | Update account status |
| `mciasdb account reset-totp --id UUID` | Clear TOTP fields (totp_required=0, totp_secret_enc=NULL) |

**Role management (offline):**

| Command | Description |
|---|---|
| `mciasdb role list --id UUID` | List roles for account |
| `mciasdb role grant --id UUID --role ROLE` | Insert role row |
| `mciasdb role revoke --id UUID --role ROLE` | Delete role row |

**Token management (offline):**

| Command | Description |
|---|---|
| `mciasdb token list --id UUID` | List token_revocation rows for account |
| `mciasdb token revoke --jti JTI` | Mark JTI as revoked in token_revocation |
| `mciasdb token revoke-all --id UUID` | Revoke all active tokens for account |

**Audit log:**

| Command | Description |
|---|---|
| `mciasdb audit tail [--n N]` | Print last N audit events (default 50) |
| `mciasdb audit query --account UUID` | Print audit events for account |
| `mciasdb audit query --type EVENT_TYPE` | Print audit events of given type |
| `mciasdb audit query --since TIMESTAMP` | Print audit events since RFC-3339 time |

**Postgres credentials (offline):**

| Command | Description |
|---|---|
| `mciasdb pgcreds get --id UUID` | Decrypt and print Postgres credentials |
| `mciasdb pgcreds set --id UUID ...` | Encrypt and store Postgres credentials |

### Security Constraints

- `mciasdb account set-password` must prompt interactively (no `--password`
  flag) so the password is never present in shell history or process listings.
- Decrypted secrets (TOTP secrets, Postgres passwords) are printed only when
  explicitly requested and include a warning that output should not be logged.
- All writes produce an audit log entry tagged with actor `mciasdb`.
- `mciasdb` must not start mciassrv or bind any network port.
- mciasdb must refuse to open the DB if mciassrv holds an exclusive WAL lock;
  SQLite busy-timeout handles this gracefully (5s then error).

### Output Format

By default all output is human-readable text. `--json` flag switches to
newline-delimited JSON for scripting. Credential fields follow the same
`json:"-"` exclusion rules as the API — they are only printed when the
specific `get` or `pgcreds get` command is invoked, never in list output.

---

## 17. gRPC Interface (Phase 7)

### Rationale

The REST API is the primary interface and will remain so. A gRPC interface is
added as an alternate transport for clients that prefer strongly-typed stubs,
streaming, or lower per-request overhead. The two interfaces are strictly
equivalent in capability and security posture; they share all business logic
in the `internal/` packages.

gRPC is not a replacement for REST. Both listeners run concurrently. Operators
may disable the gRPC listener by omitting `grpc_addr` from config.

### Proto Package Layout

```
proto/
└── mcias/
    └── v1/
        ├── auth.proto       # Login, Logout, Renew, TOTP enroll/confirm/remove
        ├── token.proto      # Validate, Issue, Revoke
        ├── account.proto    # CRUD for accounts, roles, and credentials
        ├── policy.proto     # Policy rule CRUD (PolicyService)
        ├── admin.proto      # Health, public-key retrieval
        └── common.proto     # Shared message types (Error, Timestamp wrappers)

gen/
└── mcias/
    └── v1/                  # Generated Go stubs (protoc output)
```

Generated code is committed to the repository under `gen/`. The generator
is invoked via `go generate ./...`, which runs the `protoc` command declared
in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.

### Service Definitions (summary)

| Service | RPCs |
|---|---|
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
| `AdminService` | `Health`, `GetPublicKey` |

All request/response messages follow the same credential-exclusion rules as
the JSON API: `PasswordHash`, `TOTPSecret*`, and `PGPassword` fields are
never present in any response message.

### Transport Security

- The gRPC server uses the same TLS certificate and key as the REST server.
  TLS 1.2 minimum is enforced via `tls.Config` (identical to the REST server).
- Mutual TLS is out of scope for v1 but is architecturally compatible (the
  `tls.Config` can be extended).
- No plaintext (h2c) mode is provided. Connecting without TLS is refused.

### Authentication and Authorization

Authentication in gRPC uses the same JWT validation logic as the REST
middleware:

1. The gRPC unary interceptor extracts the `authorization` metadata key.
2. It expects the value `Bearer <token>` (case-insensitive prefix).
3. The token is validated via `internal/token.ValidateToken` — same alg-first
   check, same revocation table lookup.
4. Claims are injected into the `context.Context` for downstream handlers.
5. Admin RPCs are guarded by a second interceptor that checks the `admin` role
   in the injected claims — identical to the REST `RequireRole` middleware.

A missing or invalid token returns `codes.Unauthenticated`. Insufficient role
returns `codes.PermissionDenied`. No credential material is included in error
details.

### Interceptor Chain

```
[Sealed Interceptor] → [Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
```

- **Sealed Interceptor**: first in chain; blocks all RPCs with
  `codes.Unavailable` ("vault sealed") when the vault is sealed, except
  `AdminService/Health` which returns the sealed status.
- **Request Logger**: logs method, peer IP, status code, duration; never logs
  the `authorization` metadata value.
- **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs
  (`Health`, `GetPublicKey`, `ValidateToken`) bypass auth.
- **Rate Limiter**: per-IP token bucket with the same parameters as the REST
  rate limiter (10 req/s burst). Exceeding the limit returns `codes.ResourceExhausted`.

### Dual-Stack Operation

mciassrv starts both listeners in the same process:

```
┌──────────────────────────────────────────────┐
│                mciassrv process               │
│                                              │
│  ┌────────────────┐   ┌────────────────────┐  │
│  │  REST listener  │   │  gRPC listener     │  │
│  │  (net/http)     │   │  (google.golang.   │  │
│  │  :8443          │   │   org/grpc) :9443  │  │
│  └───────┬─────────┘   └──────────┬─────────┘  │
│          └──────────────┬─────────┘           │
│                         ▼                     │
│           ┌─────────────────────────────┐     │
│           │  Shared: signing key, DB,   │     │
│           │  config, rate-limit state   │     │
│           └─────────────────────────────┘     │
└──────────────────────────────────────────────┘
```

Both listeners share a single `*db.DB` connection, the same in-memory signing
key, and the same rate-limiter state. Graceful shutdown drains both within the
configured window.

### Configuration Addition

```toml
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr   = "0.0.0.0:9443"   # optional; omit to disable gRPC
tls_cert    = "/etc/mcias/server.crt"
tls_key     = "/etc/mcias/server.key"
```

### `cmd/mciasgrpcctl` — gRPC Admin CLI

An optional companion CLI (`mciasgrpcctl`) provides the same subcommands as
`mciasctl` but over gRPC. It is a thin client that wraps the generated stubs.
Auth and CA-cert flags are identical to `mciasctl`. Both CLIs can coexist;
neither depends on the other.

---

## 18. Operational Artifacts (Phase 8)

### Artifact Inventory

| Artifact | Path | Purpose |
|---|---|---|
| systemd unit | `dist/mcias.service` | Production service management |
| Environment template | `dist/mcias.env.example` | Master key and other secrets |
| Reference config | `dist/mcias.conf.example` | Annotated production config |
| Dev config | `dist/mcias-dev.conf.example` | Local development defaults |
| Docker config | `dist/mcias.conf.docker.example` | Config template for container deployment |
| Install script | `dist/install.sh` | First-time setup on a Linux host |
| Dockerfile | `Dockerfile` | Multi-stage image for container deployment |
| Man page: mciassrv | `man/man1/mciassrv.1` | Server binary reference |
| Man page: mciasctl | `man/man1/mciasctl.1` | Admin CLI reference |
| Man page: mciasdb | `man/man1/mciasdb.1` | DB tool reference |
| Man page: mciasgrpcctl | `man/man1/mciasgrpcctl.1` | gRPC CLI reference |
| Makefile | `Makefile` | Build, test, lint, install, release, docker targets |

### systemd Unit Design

The service unit applies a conservative sandboxing profile:

- `User=mcias` / `Group=mcias` — no root privileges required
- `ProtectSystem=strict` — filesystem read-only except declared `ReadWritePaths`
- `ReadWritePaths=/var/lib/mcias` — SQLite database directory only
- `PrivateTmp=true` — isolated `/tmp`
- `NoNewPrivileges=true` — seccomp/capability escalation blocked
- `CapabilityBoundingSet=` — empty; no Linux capabilities needed (port ≥ 1024)
- `EnvironmentFile=/etc/mcias/env` — secrets injected from file, not inline

The unit does not start the service on install. Operators must run
`systemctl enable --now mcias` explicitly after verifying configuration.

### Filesystem Layout (post-install)

```
/usr/local/bin/
  mciassrv
  mciasctl
  mciasdb
  mciasgrpcctl        (if gRPC phase installed)

/etc/mcias/
  mcias.conf          (config file; mode 0640, owner root:mcias)
  env                 (environment file with MCIAS_MASTER_PASSPHRASE; mode 0640)
  server.crt          (TLS certificate; mode 0644)
  server.key          (TLS private key; mode 0640, owner root:mcias)

/var/lib/mcias/
  mcias.db            (SQLite database; mode 0660, owner mcias:mcias)

/usr/share/man/man1/
  mciassrv.1.gz
  mciasctl.1.gz
  mciasdb.1.gz
  mciasgrpcctl.1.gz
```

### Dockerfile Design

The image uses a two-stage build to keep the runtime image small and free of
build toolchain:

```
# Stage 1 — build
FROM golang:1.26-bookworm AS builder
  CGO_ENABLED=1 (SQLite requires cgo)
  -trimpath -ldflags="-s -w" (strip DWARF and symbol table)
  Builds: mciassrv, mciasctl, mciasdb, mciasgrpcctl

# Stage 2 — runtime
FROM debian:bookworm-slim
  Installs: ca-certificates, libc6
  Copies binaries from builder stage only
  Creates uid/gid 10001 (mcias:mcias)
  EXPOSE 8443 (REST/TLS) and 9443 (gRPC/TLS)
  VOLUME /data  (SQLite database mount point)
  ENTRYPOINT ["mciassrv"]
  CMD ["-config", "/etc/mcias/mcias.conf"]
```

Security properties of the runtime image:

- No Go toolchain, no build cache, no source code — minimal attack surface
- Non-root user (`mcias`, uid 10001) — no escalation path
- TLS termination happens inside the container (same cert/key as bare-metal
  deployment); the operator mounts `/etc/mcias/` as a read-only volume
  containing the config file, TLS cert, and TLS key
- The SQLite database is on a named volume at `/data`; the operator is
  responsible for backup; no network storage is assumed

Operator workflow:

```
# Build image
docker build -t mcias:$(git describe --tags --always) .

# Run (example)
docker run -d \
  --name mcias \
  -v /path/to/config:/etc/mcias:ro \
  -v mcias-data:/data \
  -p 8443:8443 \
  -p 9443:9443 \
  mcias:latest
```

The Makefile `docker` target automates the build step with the version tag.

### Makefile Targets

| Target | Action |
|---|---|
| `build` | Compile all binaries to `bin/` using current GOOS/GOARCH |
| `test` | `go test -race ./...` |
| `lint` | `golangci-lint run ./...` |
| `generate` | `go generate ./...` (re-generates proto stubs) |
| `man` | Build man pages; compress to `.gz` in `man/` |
| `install` | Run `dist/install.sh` |
| `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
| `docker-clean` | Remove local `mcias:$(VERSION)` and `mcias:latest` images; prune dangling images with the mcias label |
| `clean` | Remove `bin/`, compressed man pages, and local Docker images |
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |

### Upgrade Path

The install script is idempotent. Running it again after a new release:
1. Overwrites binaries in `/usr/local/bin/`
2. Does **not** overwrite `/etc/mcias/mcias.conf` or `/etc/mcias/env` (backs
   them up with a `.bak` suffix and skips if unchanged)
3. Does **not** run `mciasdb schema migrate` automatically — the operator
   must do this manually before restarting the service

---

## 19. Client Libraries (Phase 9)

### Design Goals

Client libraries exist to make it easy for relying-party applications to
authenticate users via MCIAS without needing to understand JWT handling, TLS
configuration, or the HTTP API wire format. Each library:

1. Exposes the canonical API surface (defined in `clients/README.md`).
2. Handles token storage, renewal, and error classification internally.
3. Enforces TLS (no plaintext) and validates the server certificate by default.
4. Never logs or exposes credential material.
5. Is independently versioned and testable.

### Canonical API Surface

Every language implementation must expose:

```
Client(server_url, [ca_cert], [token])

# Authentication
client.login(username, password, [totp_code]) → (token, expires_at)
client.logout() → void
client.renew_token() → (token, expires_at)

# Token operations
client.validate_token(token) → claims
client.get_public_key() → jwk

# Health
client.health() → void  # raises/errors on failure

# Account management (admin)
client.create_account(username, type) → account
client.list_accounts() → [account]
client.get_account(id) → account
client.update_account(id, updates) → account
client.delete_account(id) → void

# Role management (admin)
client.get_roles(account_id) → [role]
client.set_roles(account_id, roles) → void

# Token management (admin or role-scoped)
client.issue_service_token(account_id) → (token, expires_at)
client.revoke_token(jti) → void

# PG credentials (admin or role-scoped)
client.get_pg_creds(account_id) → pg_creds
client.set_pg_creds(account_id, pg_creds) → void
```

Error types exposed by every library:

| Error | Meaning |
|---|---|
| `MciasAuthError` / `Unauthenticated` | Token missing, invalid, or expired |
| `MciasForbiddenError` / `PermissionDenied` | Insufficient role |
| `MciasNotFoundError` / `NotFound` | Resource does not exist |
| `MciasInputError` / `InvalidArgument` | Malformed request |
| `MciasServerError` / `Internal` | Unexpected server error |
| `MciasTransportError` | Network/TLS failure |

### Per-Language Implementation Notes

#### Go (`clients/go/`)

- Module: `git.wntrmute.dev/mc/mcias/clients/go`
- Package: `mciasgoclient`
- HTTP: `net/http` with custom `*tls.Config` for CA cert
- Token state: guarded by `sync.RWMutex`
- JSON: `encoding/json` with `DisallowUnknownFields` on all decoders
- Error wrapping: `fmt.Errorf("mciasgoclient: %w", err)` preserving cause

#### Rust (`clients/rust/`)

- Crate: `mcias-client` (published to crates.io when stable)
- Runtime: `tokio`-async; `reqwest` for HTTP
- TLS: `rustls` backend (no OpenSSL dependency); custom CA via
  `reqwest::Certificate`
- Error type: `MciasError` enum deriving `thiserror::Error`
- Serialization: `serde` + `serde_json`; strict unknown-field rejection
  via `#[serde(deny_unknown_fields)]`
- Token state: `Arc<tokio::sync::RwLock<Option<String>>>`

#### Common Lisp (`clients/lisp/`)

- ASDF system: `mcias-client` (quickload-able via Quicklisp)
- HTTP: `dexador` (synchronous)
- JSON: `yason` for both encoding and decoding; all booleans normalised
  (yason returns `:false` for JSON `false`; client coerces to `nil`)
- TLS: delegated to Dexador/Usocket/cl+ssl; custom CA documented per platform
- API: CLOS class `mcias-client` with `client-base-url` reader and
  `client-token` accessor; plain functions (not generic) for all operations
- Conditions: `mcias-error` base with subclasses `mcias-auth-error`,
  `mcias-forbidden-error`, `mcias-not-found-error`, `mcias-input-error`,
  `mcias-conflict-error`, `mcias-server-error`
- Tests: 37 checks in `fiveam`; mock server implemented with Hunchentoot
  (`mock-dispatcher` subclass overriding `handle-request`); all fiveam
  symbols explicitly prefixed to avoid SBCL package-lock violations
- Compatibility: SBCL 2.x primary

#### Python (`clients/python/`)

- Package: `mcias_client` (PEP 517 build; `pyproject.toml` / setuptools)
- HTTP: `httpx` sync client; `Client` is a context manager (`__enter__`/`__exit__`)
- TLS: `ssl.create_default_context(cafile=...)` for custom CA cert
- Types: `py.typed` marker; all public symbols fully annotated; `mypy --strict`
  passes with zero issues; dataclasses for `Account`, `PublicKey`, `PGCreds`
- Errors: `MciasError(Exception)` base with subclasses as listed above;
  `raise_for_status()` dispatcher maps status codes to typed exceptions
- Token state: `token: str | None` public attribute (single-threaded use assumed)
- Python version support: 3.11+ (uses `datetime.UTC`, `X | Y` union syntax)
- Linting: `ruff check` (E/F/W/I/UP rules, 88-char line limit); `ruff format`
- Tests: 32 pytest tests using `respx` for httpx mocking

### Versioning Strategy

Each client library follows the MCIAS server's minor version. Breaking changes
to the API surface increment the major version. The REST API surface defined in
`clients/README.md` serves as the source of truth; client libraries
implement the full surface.

Client libraries are not coupled to each other. A user of the Python library
does not need the Go library installed.

### Mock Servers

`test/mock/mockserver.go` provides a Go `httptest.Server`-compatible mock
MCIAS server (struct `Server`) for use in Go client integration tests. It
maintains in-memory account/token/revocation state with `sync.RWMutex`.

Each other language library includes its own inline mock:

- **Rust**: `wiremock::MockServer` with per-test `Mock` stubs
- **Common Lisp**: Hunchentoot acceptor (`mock-dispatcher`) in
  `tests/mock-server.lisp`; started on a random port per test via
  `start-mock-server` / `stop-mock-server`
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator

---

## 20. Authorization Policy Engine

### Motivation

The initial authorization model is binary: the `admin` role grants full access;
all other authenticated principals have access only to self-service operations
(logout, token renewal, TOTP enrollment). As MCIAS manages credentials for
multiple personal applications running on multiple machines, a richer model is
needed:

- A human account should be able to access credentials for one specific service
  without being a full admin.
- A human account should be able to issue/rotate tokens for one specific service
  account without holding the global `admin` role (see token delegation, §21).
- A system account (`deploy-agent`) should only operate on hosts tagged
  `env:staging`, not `env:production`.
- A "secrets reader" role should read pgcreds for any service but change nothing.

The policy engine adds fine-grained, attribute-based access control (ABAC) as
an in-process Go package (`internal/policy`) with no external dependencies.

### Design Principles

- **Deny-wins**: any explicit `deny` rule overrides all `allow` rules.
- **Default-deny**: if no rule matches, the request is denied.
- **Compiled-in defaults**: a set of built-in rules encoded in Go reproduces
  the previous binary behavior exactly. They cannot be disabled via the API.
- **Pure evaluation**: `Evaluate()` is a stateless function; it takes a
  `PolicyInput` and a slice of `Rule` values and returns an effect. The caller
  assembles the input from JWT claims and DB lookups; the engine never touches
  the database.
- **Auditable**: every explicit `deny` produces a `policy_deny` audit event
  recording which rule matched. Every `allow` on a sensitive resource (pgcreds,
  token issuance) is also logged.

### Core Types

```go
// package internal/policy

type Action string
type ResourceType string
type Effect string

const (
    // Actions
    ActionListAccounts  Action = "accounts:list"
    ActionCreateAccount Action = "accounts:create"
    ActionReadAccount   Action = "accounts:read"
    ActionUpdateAccount Action = "accounts:update"
    ActionDeleteAccount Action = "accounts:delete"
    ActionReadRoles     Action = "roles:read"
    ActionWriteRoles    Action = "roles:write"
    ActionReadTags      Action = "tags:read"
    ActionWriteTags     Action = "tags:write"
    ActionIssueToken    Action = "tokens:issue"
    ActionRevokeToken   Action = "tokens:revoke"
    ActionValidateToken Action = "tokens:validate"  // public
    ActionRenewToken    Action = "tokens:renew"     // self-service
    ActionReadPGCreds   Action = "pgcreds:read"
    ActionWritePGCreds  Action = "pgcreds:write"
    ActionReadAudit     Action = "audit:read"
    ActionEnrollTOTP    Action = "totp:enroll"      // self-service
    ActionRemoveTOTP    Action = "totp:remove"      // admin
    ActionLogin          Action = "auth:login"           // public
    ActionLogout         Action = "auth:logout"          // self-service
    ActionChangePassword Action = "auth:change_password" // self-service
    ActionListRules      Action = "policy:list"
    ActionManageRules   Action = "policy:manage"

    // Resource types
    ResourceAccount  ResourceType = "account"
    ResourceToken    ResourceType = "token"
    ResourcePGCreds  ResourceType = "pgcreds"
    ResourceAuditLog ResourceType = "audit_log"
    ResourceTOTP     ResourceType = "totp"
    ResourcePolicy   ResourceType = "policy"

    // Effects
    Allow Effect = "allow"
    Deny  Effect = "deny"
)

// PolicyInput is assembled by the middleware from JWT claims and request context.
// The engine never accesses the database.
type PolicyInput struct {
    Subject     string   // account UUID from JWT "sub"
    AccountType string   // "human" or "system"
    Roles       []string // role strings from JWT "roles" claim

    Action   Action
    Resource Resource
}

// Resource describes what the principal is trying to act on.
type Resource struct {
    Type        ResourceType
    OwnerUUID   string   // UUID of the account that owns this resource
                         // (e.g. the system account whose pgcreds are requested)
    ServiceName string   // username of the system account (for service-name gating)
    Tags        []string // tags on the target account, loaded from account_tags
}

// Rule is a single policy statement. All populated fields are ANDed.
// A zero/empty field is a wildcard (matches anything).
type Rule struct {
    ID          int64  // database primary key; negative for built-in rules (-1 … -7)
    Description string

    // Principal match conditions
    Roles        []string // principal must hold at least one of these roles
    AccountTypes []string // "human", "system", or both
    SubjectUUID  string   // exact principal UUID (for single-account rules)

    // Action match condition
    Actions []Action // action must be one of these

    // Resource match conditions
    ResourceType        ResourceType
    OwnerMatchesSubject bool     // true: resource.OwnerUUID must equal input.Subject
    ServiceNames        []string // resource.ServiceName must be in this list
    RequiredTags        []string // resource must carry ALL of these tags

    Effect   Effect
    Priority int // lower value = evaluated first; built-in defaults use 0
}
```

### Evaluation Algorithm

```
func Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule):
    sort rules by Priority ascending (stable)
    collect all rules that match input

    for each matched rule (in priority order):
        if rule.Effect == Deny:
            return Deny, &rule  // deny-wins: stop immediately

    for each matched rule (in priority order):
        if rule.Effect == Allow:
            return Allow, &rule

    return Deny, nil  // default-deny
```

A rule matches `input` when every populated field satisfies its condition:

| Field | Match condition |
|---|---|
| `Roles` | `input.Roles` contains at least one element of `rule.Roles` |
| `AccountTypes` | `input.AccountType` is in `rule.AccountTypes` |
| `SubjectUUID` | `input.Subject == rule.SubjectUUID` |
| `Actions` | `input.Action` is in `rule.Actions` |
| `ResourceType` | `input.Resource.Type == rule.ResourceType` |
| `OwnerMatchesSubject` | (if true) `input.Resource.OwnerUUID == input.Subject` |
| `ServiceNames` | `input.Resource.ServiceName` is in `rule.ServiceNames` |
| `RequiredTags` | `input.Resource.Tags` contains ALL elements of `rule.RequiredTags` |

### Built-in Default Rules

These rules are compiled into the binary (`internal/policy/defaults.go`). They
cannot be deleted via the API and are always evaluated before DB-backed rules
at the same priority level.

```
Priority 0, Allow: roles=[admin], actions=<all>               — admin wildcard
Priority 0, Allow: actions=[auth:logout, tokens:renew]        — self-service logout/renew
Priority 0, Allow: actions=[totp:enroll]                      — self-service TOTP enrollment
Priority 0, Allow: accountTypes=[human], actions=[auth:change_password]
                                                               — self-service password change
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
                   resourceType=pgcreds, ownerMatchesSubject=true
                                                               — system account reads own creds
Priority 0, Allow: accountTypes=[system], actions=[tokens:issue, tokens:renew],
                   resourceType=token, ownerMatchesSubject=true
                                                               — system account issues own token
Priority 0, Allow: actions=[tokens:validate, auth:login]      — public endpoints (no auth needed)
```

These defaults reproduce the previous binary `admin`/not-admin behavior exactly.
Adding custom rules extends the policy without replacing the defaults.

### Machine/Service Gating

Tags and service names enable access decisions that depend on which machine or
service the resource belongs to, not just who the principal is.

**Scenario A — Named service delegation:**

Alice needs to read Postgres credentials for the `payments-api` system account
but not for any other service. The operator grants Alice the role `svc:payments-api`
and creates one rule:

```json
{
  "roles": ["svc:payments-api"],
  "actions": ["pgcreds:read"],
  "resource_type": "pgcreds",
  "service_names": ["payments-api"],
  "effect": "allow",
  "priority": 50,
  "description": "Alice may read payments-api pgcreds"
}
```

When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
sets `resource.ServiceName = "payments-api"`. The rule matches; access is
granted. The same call against `user-service` sets a different `ServiceName`
and no rule matches — default-deny applies.

**Scenario B — Machine-tag gating:**

The `deploy-agent` system account should only read credentials for accounts
tagged `env:staging`. The operator tags staging accounts with `env:staging` and
creates:

```json
{
  "subject_uuid": "<deploy-agent UUID>",
  "actions": ["pgcreds:read"],
  "resource_type": "pgcreds",
  "required_tags": ["env:staging"],
  "effect": "allow",
  "priority": 50,
  "description": "deploy-agent may read staging pgcreds"
}
```

For belt-and-suspenders, an explicit deny for production tags:

```json
{
  "subject_uuid": "<deploy-agent UUID>",
  "resource_type": "pgcreds",
  "required_tags": ["env:production"],
  "effect": "deny",
  "priority": 10,
  "description": "deploy-agent denied production pgcreds (deny-wins)"
}
```

**Scenario C — Blanket "secrets reader" role:**

```json
{
  "roles": ["secrets-reader"],
  "actions": ["pgcreds:read"],
  "resource_type": "pgcreds",
  "effect": "allow",
  "priority": 50,
  "description": "secrets-reader role may read any pgcreds"
}
```

No `ServiceNames` or `RequiredTags` field means this matches any service account.

**Scenario D — Time-scoped access:**

The `deploy-agent` needs temporary access to production pgcreds for a 4-hour
maintenance window. Instead of creating a rule and remembering to delete it,
the operator sets `not_before` and `expires_at`:

```
mciasctl policy create \
  -description "deploy-agent: temp production access" \
  -json rule.json \
  -not-before 2026-03-12T02:00:00Z \
  -expires-at 2026-03-12T06:00:00Z
```

The policy engine filters rules at cache-load time (`Engine.SetRules`): rules
where `not_before > now()` or `expires_at <= now()` are excluded from the
cached rule set. No changes to the `Evaluate()` or `matches()` functions are
needed. Both fields are optional and nullable; `NULL` means no constraint
(always active / never expires).

### Middleware Integration

`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in
replacement for `RequireRole("admin")`. It:

1. Extracts `*token.Claims` from context (JWT already validated by `RequireAuth`).
2. Reads the resource UUID from the request path parameter.
3. Queries the database for the target account's UUID, username, and tags.
4. Assembles `PolicyInput`.
5. Calls `engine.Evaluate(input)`.
6. On `Deny`: writes a `policy_deny` audit event and returns HTTP 403.
7. On `Allow`: proceeds to the handler (and optionally writes an allow audit
   event for sensitive resources).

The `Engine` struct wraps the DB-backed rule loader. It caches the current rule
set in memory and reloads on `policy_rule_*` admin events (or on `SIGHUP`).
Built-in default rules are always merged in at priority 0.

### Migration Path

The policy engine is introduced without changing existing behavior:

1. Add `account_tags` and `policy_rules` tables (schema migration).
2. Implement `internal/policy` package with built-in defaults only.
3. Wire `RequirePolicy` in middleware alongside `RequireRole("admin")` — both
   must pass. The built-in defaults guarantee the outcome is identical to the
   previous binary check.
4. Expose REST endpoints (`/v1/policy/rules`, `/v1/accounts/{id}/tags`) and
   corresponding CLI commands and UI pages — operators can now create rules.
5. After validating custom rules in operation, `RequireRole("admin")` can be
   removed from endpoints where `RequirePolicy` provides full coverage.

Step 3 is the correctness gate: zero behavioral change before custom rules are
introduced.

### Audit Events

| Event | Trigger |
|---|---|
| `policy_deny` | Policy engine denied a request; details include `{action, resource_type, service_name, required_tags, matched_rule_id}` — never credential material |
| `policy_rule_created` | New rule created |
| `policy_rule_updated` | Rule priority, enabled flag, or description changed |
| `policy_rule_deleted` | Rule deleted |
| `tag_added` | Tag added to an account |
| `tag_removed` | Tag removed from an account |

---

## 21. Token Issuance Delegation

### Motivation

The initial design required the `admin` role to issue a service account token.
This blocks a common workflow: a developer who owns one personal app (e.g.
`payments-api`) wants to rotate its service token without granting another
person full admin access to all of MCIAS.

Token issuance delegation solves this by allowing admins to grant specific
human accounts the right to issue/rotate tokens for specific system accounts —
and nothing else.

### Model

The `service_account_delegates` table stores the delegation relationship:

```
service_account_delegates(account_id, grantee_id, granted_by, granted_at)
```

- `account_id` — the **system account** whose token the delegate may issue
- `grantee_id` — the **human account** granted the right
- `granted_by` — the admin who created the grant (for audit purposes)

A human account is a delegate if a row exists with their ID as `grantee_id`.
Delegates may:

- Issue/rotate the token for the specific system account
- Download the newly issued token via the one-time nonce endpoint
- View the system account on their `/service-accounts` page

Delegates may **not**:

- Modify roles, tags, or status on the system account
- Read or modify pgcreds for the system account
- List other accounts or perform any other admin operation

### Token Download Flow

Issuing a service token via `POST /accounts/{id}/token` (admin or delegate)
stores the raw token string in an in-memory `sync.Map` under a random nonce
with a 5-minute TTL. The handler returns the nonce in the HTMX fragment.

The caller redeems the nonce via `GET /token/download/{nonce}`, which:

1. Looks up the nonce in the map (missing → 404).
2. Deletes the nonce immediately (prevents replay).
3. Returns the token as `Content-Disposition: attachment; filename=token.txt`.

The nonce is not stored in the database and is lost on server restart. This
is intentional: if the download window is missed, the operator simply issues
a new token.

### Authorization Check

`POST /accounts/{id}/token` is authenticated (bearer JWT + CSRF) but not
admin-only. The handler performs an explicit check:

```
if claims.HasRole("admin") OR db.HasTokenIssueAccess(targetID, callerID):
    proceed
else:
    403 Forbidden
```

This check is done in the handler rather than middleware because the
delegation relationship requires a DB lookup that depends on the caller's
identity and the specific target account.

### Admin Management

| Endpoint | Description |
|---|---|
| `POST /accounts/{id}/token/delegates` | Grant delegation (admin only) |
| `DELETE /accounts/{id}/token/delegates/{grantee}` | Revoke delegation (admin only) |

Both operations produce audit events (`token_delegate_granted`,
`token_delegate_revoked`) and are visible in the account detail UI under
the "Token Issue Access" section.

### Audit Events

| Event | Trigger |
|---|---|
| `token_delegate_granted` | Admin granted a human account token-issue access for a system account |
| `token_delegate_revoked` | Admin revoked token-issue delegation |
| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) |

## 22. FIDO2/WebAuthn Authentication

### Overview

WebAuthn support enables two credential modes:
- **Discoverable credentials (passkeys)** — passwordless login. The authenticator
  stores a resident credential; the user clicks "Sign in with passkey" and the
  browser prompts for the credential directly.
- **Non-discoverable credentials (security keys)** — 2FA alongside
  username+password. The server supplies allowCredentials for the account.

Either WebAuthn or TOTP satisfies the 2FA requirement. If both are enrolled the
UI offers passkey first.

### Credential Storage

Credential IDs and public keys are encrypted at rest with AES-256-GCM using
the vault master key, consistent with TOTP secrets and PG credentials. The
nonce is stored alongside the ciphertext in the `webauthn_credentials` table.

Metadata (name, AAGUID, sign count, discoverable flag, transports, timestamps)
is stored in plaintext for display and management.

### Challenge (Ceremony) Management

Registration and login ceremonies use an in-memory `sync.Map` with 120-second
TTL, consistent with the `pendingLogins` and `tokenDownloads` patterns. Each
ceremony is keyed by a 128-bit random nonce. Ceremonies are single-use:
consumed on finish, expired entries cleaned by a background goroutine.

Separate ceremony stores exist for REST API (`internal/server`) and web UI
(`internal/ui`) to maintain independent lifecycle management.

### Sign Counter Validation

On each assertion the stored sign counter is compared to the authenticator's
reported value. If the reported counter is less than or equal to the stored
counter (and both are non-zero), the assertion is rejected as a potential
cloned authenticator. This mirrors the TOTP replay protection pattern.

### Audit Events

| Event | Description |
|---|---|
| `webauthn_enrolled` | New WebAuthn credential registered |
| `webauthn_removed` | WebAuthn credential removed (self-service or admin) |
| `webauthn_login_ok` | Successful WebAuthn authentication |
| `webauthn_login_fail` | Failed WebAuthn authentication attempt |

### Configuration

WebAuthn is enabled by adding a `[webauthn]` section to the TOML config:

```toml
[webauthn]
rp_id = "mcias.metacircular.net"
rp_origin = "https://mcias.metacircular.net:8443"
display_name = "MCIAS"
```

If the section is omitted, WebAuthn endpoints return 404 and the UI hides
passkey-related controls.
