Sync docs and fix flaky renewal e2e test

- ARCHITECTURE.md: add Vault Endpoints section, /unseal UI page,
  vault_sealed/vault_unsealed audit events, sealed interceptor in
  gRPC chain
- openapi.yaml: add /v1/vault/{status,unseal,seal} endpoints, update
  /v1/health sealed-state docs, add VaultSealed response component,
  add vault audit event types and Admin — Vault tag
- web/static/openapi.yaml: kept in sync with root
- test/e2e: increase renewal test token lifetime from 2s to 10s
  (sleep 6s) to eliminate race between token expiry and HTTP round-trip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 00:39:41 -07:00
parent d87b4b4042
commit b1b52000c4
3 changed files with 164 additions and 9 deletions

View File

@@ -431,11 +431,23 @@ All endpoints use JSON request/response bodies. All responses include a
|---|---|---|---| |---|---|---|---|
| GET | `/v1/audit` | admin JWT | List audit log events | | 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 ### Admin / Server Endpoints
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/v1/health` | none | Health check | | GET | `/v1/health` | none | Health check — returns `{"status":"ok"}` or `{"status":"sealed"}` |
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) | | GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
### Web Management UI ### Web Management UI
@@ -458,6 +470,7 @@ cookie pattern (`mcias_csrf`).
| Path | Description | | Path | Description |
|---|---| |---|---|
| `/unseal` | Passphrase form to unseal the vault; shown for all paths when sealed |
| `/login` | Username/password login with optional TOTP step | | `/login` | Username/password login with optional TOTP step |
| `/` | Dashboard (account summary) | | `/` | Dashboard (account summary) |
| `/accounts` | Account list | | `/accounts` | Account list |
@@ -797,6 +810,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) | | `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
| `policy_rule_deleted` | Policy rule deleted | | `policy_rule_deleted` | Policy rule deleted |
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) | | `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
| `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` |
--- ---
@@ -1010,9 +1025,12 @@ details.
### Interceptor Chain ### Interceptor Chain
``` ```
[Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler] [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 - **Request Logger**: logs method, peer IP, status code, duration; never logs
the `authorization` metadata value. the `authorization` metadata value.
- **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs - **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs

View File

@@ -307,6 +307,18 @@ components:
error: rate limit exceeded error: rate limit exceeded
code: rate_limited code: rate_limited
VaultSealed:
description: |
The vault is sealed. The server is running but has no key material.
Unseal via `POST /v1/vault/unseal` before retrying.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: vault is sealed
code: vault_sealed
paths: paths:
# ── Public ──────────────────────────────────────────────────────────────── # ── Public ────────────────────────────────────────────────────────────────
@@ -314,12 +326,17 @@ paths:
/v1/health: /v1/health:
get: get:
summary: Health check summary: Health check
description: Returns `{"status":"ok"}` if the server is running. No auth required. description: |
Returns server health status. Always returns HTTP 200, even when the
vault is sealed. No auth required.
When the vault is sealed, `status` is `"sealed"` and most other
endpoints return 503. When healthy, `status` is `"ok"`.
operationId: getHealth operationId: getHealth
tags: [Public] tags: [Public]
responses: responses:
"200": "200":
description: Server is healthy. description: Server is running (check `status` for sealed state).
content: content:
application/json: application/json:
schema: schema:
@@ -327,6 +344,7 @@ paths:
properties: properties:
status: status:
type: string type: string
enum: [ok, sealed]
example: ok example: ok
/v1/keys/public: /v1/keys/public:
@@ -369,6 +387,121 @@ paths:
description: Base64url-encoded public key bytes. description: Base64url-encoded public key bytes.
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
/v1/vault/status:
get:
summary: Vault seal status
description: |
Returns whether the vault is currently sealed. Always accessible,
even when sealed. No auth required.
Clients should poll this after startup or after a 503 `vault_sealed`
response to determine when to attempt an unseal.
operationId: getVaultStatus
tags: [Public]
responses:
"200":
description: Current vault seal state.
content:
application/json:
schema:
type: object
required: [sealed]
properties:
sealed:
type: boolean
example: false
/v1/vault/unseal:
post:
summary: Unseal the vault
description: |
Provide the master passphrase to derive the encryption key, decrypt
the Ed25519 signing key, and unseal the vault. Once unsealed, all
other endpoints become available.
Rate limited to 3 requests per second per IP (burst 5) to limit
brute-force attempts against the passphrase.
The passphrase is never logged. A generic `"unseal failed"` error
is returned for any failure (wrong passphrase, vault already unsealed
mid-flight, etc.) to avoid leaking information.
operationId: unsealVault
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [passphrase]
properties:
passphrase:
type: string
description: Master passphrase used to derive the encryption key.
example: correct-horse-battery-staple
responses:
"200":
description: Vault unsealed (or was already unsealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [unsealed, already unsealed]
example: unsealed
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Wrong passphrase or key decryption failure.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: unseal failed
code: unauthorized
"429":
$ref: "#/components/responses/RateLimited"
/v1/vault/seal:
post:
summary: Seal the vault (admin)
description: |
Zero all key material in memory and transition the server to the
sealed state. After this call:
- All subsequent requests (except health, vault status, and unseal)
return 503 `vault_sealed`.
- The caller's own JWT is immediately invalidated because the public
key needed to verify it is no longer held in memory.
- The server can be unsealed again via `POST /v1/vault/unseal`.
This is an emergency operation. Use it to protect key material if a
compromise is suspected. It does **not** restart the server or wipe
the database.
operationId: sealVault
tags: [Admin — Vault]
security:
- bearerAuth: []
responses:
"200":
description: Vault sealed (or was already sealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [sealed, already sealed]
example: sealed
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/auth/login: /v1/auth/login:
post: post:
summary: Login summary: Login
@@ -1148,7 +1281,7 @@ paths:
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`, `pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
`pgcred_access_revoked`, `tag_added`, `tag_removed`, `pgcred_access_revoked`, `tag_added`, `tag_removed`,
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`, `policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
`policy_deny`. `policy_deny`, `vault_sealed`, `vault_unsealed`.
operationId: listAudit operationId: listAudit
tags: [Admin — Audit] tags: [Admin — Audit]
security: security:
@@ -1530,3 +1663,5 @@ tags:
description: Requires admin role. description: Requires admin role.
- name: Admin — Policy - name: Admin — Policy
description: Requires admin role. Manage policy rules and account tags. description: Requires admin role. Manage policy rules and account tags.
- name: Admin — Vault
description: Requires admin role. Emergency vault seal operation.

View File

@@ -227,9 +227,11 @@ func TestE2ETokenRenewal(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
acct := e.createAccount(t, "bob") acct := e.createAccount(t, "bob")
// Issue a short-lived token (2s) directly so we can wait past the 50% // Issue a short-lived token (10s) directly so we can wait past the 50%
// renewal threshold (SEC-03) without blocking the test for minutes. // renewal threshold (SEC-03) without blocking the test for minutes.
oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 2*time.Second) // 10s gives ample headroom: we sleep 6s (>50%), leaving 4s for the HTTP
// round-trip before expiry — eliminating the race that plagued the 2s token.
oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 10*time.Second)
if err != nil { if err != nil {
t.Fatalf("IssueToken: %v", err) t.Fatalf("IssueToken: %v", err)
} }
@@ -237,8 +239,8 @@ func TestE2ETokenRenewal(t *testing.T) {
t.Fatalf("TrackToken: %v", err) t.Fatalf("TrackToken: %v", err)
} }
// Wait for >50% of the 2s lifetime to elapse. // Wait for >50% of the 10s lifetime to elapse.
time.Sleep(1100 * time.Millisecond) time.Sleep(6 * time.Second)
// Renew. // Renew.
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken) resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)