- ARCHITECTURE.md: add gRPC listener, mciasgrpcctl, new roles, granular role endpoints, profile page, audit events, policy actions, trusted_proxy config, validate package, schema force command - PROGRESS.md: document role expansion and UI privilege escalation fix - PROJECT_PLAN.md: align mciasctl subcommands with implementation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1666 lines
69 KiB
Markdown
1666 lines
69 KiB
Markdown
# 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
|
|
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1)
|
|
|
|
**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 |
|
|
|
|
### Account Endpoints (admin only)
|
|
|
|
| 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 |
|
|
|
|
### 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) |
|
|
|
|
### Postgres Credential Endpoints
|
|
|
|
| Method | Path | Auth required | Description |
|
|
|---|---|---|---|
|
|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
|
| 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 |
|
|
|
|
### Admin / Server Endpoints
|
|
|
|
| Method | Path | Auth required | Description |
|
|
|---|---|---|---|
|
|
| GET | `/v1/health` | none | Health check |
|
|
| 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 |
|
|
|---|---|
|
|
| `/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) |
|
|
|
|
**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)
|
|
);
|
|
```
|
|
|
|
### 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 and Postgres passwords 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 = "720h" # 30 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 (Phase 6)
|
|
│ │ └── main.go
|
|
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
|
|
│ └── main.go
|
|
├── internal/
|
|
│ ├── 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)
|
|
│ ├── grpcserver/ # gRPC handler implementations (Phase 7)
|
|
│ ├── 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)
|
|
├── web/
|
|
│ ├── static/ # CSS and static assets
|
|
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
|
├── proto/
|
|
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
|
├── gen/
|
|
│ └── mcias/v1/ # Generated Go stubs from protoc (committed; Phase 7)
|
|
└── 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) |
|
|
|
|
---
|
|
|
|
## 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 and roles
|
|
├── 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` |
|
|
| `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
|
|
|
|
```
|
|
[Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
|
|
```
|
|
|
|
- **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) .` |
|
|
| `clean` | Remove `bin/` and compressed man pages |
|
|
| `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/kyle/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 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; 0 for built-in rules
|
|
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 |
|