diff --git a/openapi.yaml b/openapi.yaml index b9611ba..a267e06 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -100,13 +100,15 @@ components: format: date-time example: "2026-03-11T09:01:23Z" actor_id: - type: [string, "null"] + type: string format: uuid + nullable: true description: UUID of the account that performed the action. Null for bootstrap events. example: 550e8400-e29b-41d4-a716-446655440000 target_id: - type: [string, "null"] + type: string format: uuid + nullable: true description: UUID of the affected account, if applicable. ip_address: type: string @@ -205,16 +207,18 @@ components: type: boolean example: true not_before: - type: [string, "null"] + type: string format: date-time + nullable: true description: | Earliest time the rule becomes active. NULL means no constraint (always active). Rules where `not_before > now()` are skipped during evaluation. example: "2026-04-01T00:00:00Z" expires_at: - type: [string, "null"] + type: string format: date-time + nullable: true description: | Time after which the rule is no longer active. NULL means no constraint (never expires). Rules where expires_at is in the past @@ -602,6 +606,10 @@ paths: format: uuid description: Subject (account UUID). Present when valid=true. example: 550e8400-e29b-41d4-a716-446655440000 + username: + type: string + description: Account username. Present when valid=true and the account exists. + example: alice roles: type: array items: @@ -615,7 +623,7 @@ paths: example: "2026-04-10T12:34:56Z" examples: valid: - value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"} + value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"} invalid: value: {valid: false} "429": @@ -1263,6 +1271,70 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/pgcreds: + get: + summary: List accessible Postgres credentials + description: | + Return all Postgres credentials accessible to the authenticated account: + credentials owned by the account plus any explicitly granted by an admin. + + The `id` field is the credential record ID; use it together with the + `service_account_id` to fetch full details via + `GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this + endpoint. + operationId: listAccessiblePGCreds + tags: [Admin — Credentials] + security: + - bearerAuth: [] + responses: + "200": + description: Array of accessible Postgres credential summaries. + content: + application/json: + schema: + type: array + items: + type: object + required: [id, service_account_id, host, port, database, username, created_at, updated_at] + properties: + id: + type: integer + description: Credential record ID. + example: 7 + service_account_id: + type: string + format: uuid + description: UUID of the system account that owns these credentials. + example: 550e8400-e29b-41d4-a716-446655440000 + service_account_name: + type: string + description: Username of the owning system account (omitted if unavailable). + example: payments-api + host: + type: string + example: db.example.com + port: + type: integer + example: 5432 + database: + type: string + example: mydb + username: + type: string + example: myuser + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + updated_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/VaultSealed" + /v1/audit: get: summary: Query audit log (admin) diff --git a/web/static/openapi.yaml b/web/static/openapi.yaml index 662db0e..a267e06 100644 --- a/web/static/openapi.yaml +++ b/web/static/openapi.yaml @@ -118,6 +118,121 @@ components: description: JSON blob with event-specific metadata. Never contains credentials. example: '{"jti":"f47ac10b-..."}' + TagsResponse: + type: object + required: [tags] + properties: + tags: + type: array + items: + type: string + description: Current tag list for the account. + example: ["env:production", "svc:payments-api"] + + RuleBody: + type: object + required: [effect] + description: | + The match conditions and effect of a policy rule. All fields except + `effect` are optional; an omitted field acts as a wildcard. + properties: + effect: + type: string + enum: [allow, deny] + example: allow + roles: + type: array + items: + type: string + description: Subject must have at least one of these roles. + example: ["svc:payments-api"] + account_types: + type: array + items: + type: string + enum: [human, system] + description: Subject account type must be one of these. + example: ["system"] + subject_uuid: + type: string + format: uuid + description: Match only this specific subject UUID. + example: 550e8400-e29b-41d4-a716-446655440000 + actions: + type: array + items: + type: string + description: | + One of the defined action constants, e.g. `pgcreds:read`, + `accounts:list`. Subject action must be in this list. + example: ["pgcreds:read"] + resource_type: + type: string + description: Resource type the rule applies to. + example: pgcreds + owner_matches_subject: + type: boolean + description: Resource owner UUID must equal the subject UUID. + example: true + service_names: + type: array + items: + type: string + description: Resource service name must be one of these. + example: ["payments-api"] + required_tags: + type: array + items: + type: string + description: Resource must have ALL of these tags. + example: ["env:staging"] + + PolicyRule: + type: object + required: [id, priority, description, rule, enabled, created_at, updated_at] + properties: + id: + type: integer + example: 1 + priority: + type: integer + description: Lower number = evaluated first. + example: 100 + description: + type: string + example: Allow payments-api to read its own pgcreds + rule: + $ref: "#/components/schemas/RuleBody" + enabled: + type: boolean + example: true + not_before: + type: string + format: date-time + nullable: true + description: | + Earliest time the rule becomes active. NULL means no constraint + (always active). Rules where `not_before > now()` are skipped + during evaluation. + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + nullable: true + description: | + Time after which the rule is no longer active. NULL means no + constraint (never expires). Rules where expires_at is in the past + are skipped during evaluation. + example: "2026-06-01T00:00:00Z" + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + updated_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + PGCreds: type: object required: [host, port, database, username, password] @@ -192,6 +307,18 @@ components: error: rate limit exceeded 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: # ── Public ──────────────────────────────────────────────────────────────── @@ -200,14 +327,16 @@ paths: get: summary: Health check description: | - Returns `{"status":"ok"}` if the server is running and the vault is - unsealed, or `{"status":"sealed"}` if the vault is sealed. - No auth required. + 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 tags: [Public] responses: "200": - description: Server is healthy (may be sealed). + description: Server is running (check `status` for sealed state). content: application/json: schema: @@ -218,84 +347,6 @@ paths: enum: [ok, sealed] example: ok - /v1/vault/status: - get: - summary: Vault seal status - description: Returns `{"sealed": true}` or `{"sealed": false}`. No auth required. - operationId: getVaultStatus - tags: [Vault] - responses: - "200": - description: Current seal state. - content: - application/json: - schema: - type: object - properties: - sealed: - type: boolean - - /v1/vault/unseal: - post: - summary: Unseal the vault - description: | - Accepts a passphrase, derives the master key, and unseals the vault. - Rate-limited to 3 requests per second, burst of 5. - No auth required (the vault is sealed, so no tokens can be validated). - operationId: unsealVault - tags: [Vault] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [passphrase] - properties: - passphrase: - type: string - description: Master passphrase for key derivation. - responses: - "200": - description: Vault unsealed successfully. - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: unsealed - "401": - description: Unseal failed (wrong passphrase). - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/vault/seal: - post: - summary: Seal the vault - description: | - Seals the vault, zeroing all key material in memory. - Requires admin authentication. The caller's token becomes invalid - after sealing. - operationId: sealVault - tags: [Vault] - security: - - bearerAuth: [] - responses: - "200": - description: Vault sealed successfully. - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: sealed - /v1/keys/public: get: summary: Ed25519 public key (JWK) @@ -336,6 +387,121 @@ paths: description: Base64url-encoded public key bytes. 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: post: summary: Login @@ -440,6 +606,10 @@ paths: format: uuid description: Subject (account UUID). Present when valid=true. example: 550e8400-e29b-41d4-a716-446655440000 + username: + type: string + description: Account username. Present when valid=true and the account exists. + example: alice roles: type: array items: @@ -453,7 +623,7 @@ paths: example: "2026-04-10T12:34:56Z" examples: valid: - value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"} + value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"} invalid: value: {valid: false} "429": @@ -578,6 +748,68 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/auth/password: + put: + summary: Change own password (self-service) + description: | + Change the password of the currently authenticated human account. + The caller must supply the correct `current_password` to prevent + token-theft attacks: possession of a valid JWT alone is not sufficient. + + On success: + - The stored Argon2id hash is replaced with the new password hash. + - All active sessions *except* the caller's current token are revoked. + - The lockout failure counter is cleared. + + On failure (wrong current password): + - A login failure is recorded against the account, subject to the + same lockout rules as `POST /v1/auth/login`. + + Only applies to human accounts. System accounts have no password. + operationId: changePassword + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + description: The account's current password (required for verification). + example: old-s3cr3t + new_password: + type: string + description: The new password. Minimum 12 characters. + example: new-s3cr3t-long + responses: + "204": + description: Password changed. Other active sessions revoked. + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Current password is incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: current password is incorrect + code: unauthorized + "429": + description: Account temporarily locked due to too many failed attempts. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: account temporarily locked + code: account_locked + # ── Admin ────────────────────────────────────────────────────────────────── /v1/auth/totp: @@ -911,6 +1143,76 @@ paths: "404": $ref: "#/components/responses/NotFound" + post: + summary: Grant a role to an account (admin) + description: | + Add a single role to an account's role set. If the role already exists, + this is a no-op. Roles take effect in the **next** token issued or + renewed; existing tokens continue to carry the roles embedded at + issuance time. + operationId: grantRole + tags: [Admin — Accounts] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + example: editor + responses: + "204": + description: Role granted. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/accounts/{id}/roles/{role}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + - name: role + in: path + required: true + schema: + type: string + example: editor + + delete: + summary: Revoke a role from an account (admin) + description: | + Remove a single role from an account's role set. Roles take effect in + the **next** token issued or renewed; existing tokens continue to carry + the roles embedded at issuance time. + operationId: revokeRole + tags: [Admin — Accounts] + security: + - bearerAuth: [] + responses: + "204": + description: Role revoked. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /v1/accounts/{id}/pgcreds: parameters: - name: id @@ -969,6 +1271,70 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/pgcreds: + get: + summary: List accessible Postgres credentials + description: | + Return all Postgres credentials accessible to the authenticated account: + credentials owned by the account plus any explicitly granted by an admin. + + The `id` field is the credential record ID; use it together with the + `service_account_id` to fetch full details via + `GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this + endpoint. + operationId: listAccessiblePGCreds + tags: [Admin — Credentials] + security: + - bearerAuth: [] + responses: + "200": + description: Array of accessible Postgres credential summaries. + content: + application/json: + schema: + type: array + items: + type: object + required: [id, service_account_id, host, port, database, username, created_at, updated_at] + properties: + id: + type: integer + description: Credential record ID. + example: 7 + service_account_id: + type: string + format: uuid + description: UUID of the system account that owns these credentials. + example: 550e8400-e29b-41d4-a716-446655440000 + service_account_name: + type: string + description: Username of the owning system account (omitted if unavailable). + example: payments-api + host: + type: string + example: db.example.com + port: + type: integer + example: 5432 + database: + type: string + example: mydb + username: + type: string + example: myuser + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + updated_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/VaultSealed" + /v1/audit: get: summary: Query audit log (admin) @@ -980,7 +1346,10 @@ paths: `token_issued`, `token_renewed`, `token_revoked`, `token_expired`, `account_created`, `account_updated`, `account_deleted`, `role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`, - `pgcred_accessed`, `pgcred_updated`. + `pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`, + `pgcred_access_revoked`, `tag_added`, `tag_removed`, + `policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`, + `policy_deny`, `vault_sealed`, `vault_unsealed`. operationId: listAudit tags: [Admin — Audit] security: @@ -1041,6 +1410,310 @@ paths: "403": $ref: "#/components/responses/Forbidden" + /v1/accounts/{id}/tags: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + + get: + summary: Get account tags (admin) + description: | + Return the current tag set for an account. Tags are used by the policy + engine for machine/service gating (e.g. `env:production`, + `svc:payments-api`). + operationId: getAccountTags + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Tag list. + content: + application/json: + schema: + $ref: "#/components/schemas/TagsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + put: + summary: Set account tags (admin) + description: | + Replace the account's full tag set atomically. Pass an empty array to + clear all tags. Changes take effect immediately for new policy + evaluations; no token renewal is required. + operationId: setAccountTags + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tags] + properties: + tags: + type: array + items: + type: string + example: ["env:production", "svc:payments-api"] + responses: + "200": + description: Updated tag list. + content: + application/json: + schema: + $ref: "#/components/schemas/TagsResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/accounts/{id}/password: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + + put: + summary: Admin password reset (admin) + description: | + Reset the password for a human account without requiring the current + password. This is intended for account recovery (e.g. a user forgot + their password). + + On success: + - The stored Argon2id hash is replaced with the new password hash. + - All active sessions for the target account are revoked. + + Only applies to human accounts. The new password must be at least + 12 characters. + operationId: adminSetPassword + tags: [Admin — Accounts] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [new_password] + properties: + new_password: + type: string + description: The new password. Minimum 12 characters. + example: new-s3cr3t-long + responses: + "204": + description: Password reset. All active sessions for the account revoked. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/policy/rules: + get: + summary: List policy rules (admin) + description: | + Return all operator-defined policy rules ordered by priority (ascending). + Built-in default rules (IDs -1 to -7) are not included. + operationId: listPolicyRules + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Array of policy rules. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PolicyRule" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + post: + summary: Create policy rule (admin) + description: | + Create a new operator policy rule. Rules are evaluated in priority order + (lower number = evaluated first, default 100). Deny-wins: if any matching + rule has effect `deny`, access is denied regardless of allow rules. + operationId: createPolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [description, rule] + properties: + description: + type: string + example: Allow payments-api to read its own pgcreds + priority: + type: integer + description: Evaluation priority. Lower = first. Default 100. + example: 50 + rule: + $ref: "#/components/schemas/RuleBody" + not_before: + type: string + format: date-time + description: Earliest activation time (RFC3339, optional). + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + description: Expiry time (RFC3339, optional). + example: "2026-06-01T00:00:00Z" + responses: + "201": + description: Rule created. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /v1/policy/rules/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 1 + + get: + summary: Get policy rule (admin) + operationId: getPolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Policy rule. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + patch: + summary: Update policy rule (admin) + description: | + Update one or more fields of an existing policy rule. All fields are + optional; omitted fields are left unchanged. + operationId: updatePolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: string + example: Updated description + priority: + type: integer + example: 75 + enabled: + type: boolean + example: false + rule: + $ref: "#/components/schemas/RuleBody" + not_before: + type: string + format: date-time + description: Set earliest activation time (RFC3339). + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + description: Set expiry time (RFC3339). + example: "2026-06-01T00:00:00Z" + clear_not_before: + type: boolean + description: Set to true to remove not_before constraint. + clear_expires_at: + type: boolean + description: Set to true to remove expires_at constraint. + responses: + "200": + description: Updated rule. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + delete: + summary: Delete policy rule (admin) + description: Permanently delete a policy rule. This action cannot be undone. + operationId: deletePolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "204": + description: Rule deleted. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + tags: - name: Public description: No authentication required. @@ -1056,3 +1729,7 @@ tags: description: Requires admin role. - name: Admin — Audit description: Requires admin role. + - name: Admin — Policy + description: Requires admin role. Manage policy rules and account tags. + - name: Admin — Vault + description: Requires admin role. Emergency vault seal operation.