docs(arch-h1): Phase 13 Sprint 13.5 — OpenAPI breakglass + users + runtime-config ops (batch 2, 8 ops)

Phase 13 Sprint 13.5 closure (architecture diligence audit ARCH-H1):
authors OpenAPI operations for the auth/breakglass admin cluster
(4) + auth/users cluster (3) + auth/runtime-config (1), drives the
`rest-deferred` exception bucket from 15 → 7.

OpenAPI-only sprint: zero Go changes. Every schema field-by-field
mirrors the projection types in
internal/api/handler/auth_breakglass.go +
internal/api/handler/auth_users.go.

8 new operations
================

  Break-glass admin cluster (4 ops, all gated `auth.breakglass.admin`):
    GET    /api/v1/auth/breakglass/credentials                       listBreakglassCredentials
    POST   /api/v1/auth/breakglass/credentials                       setBreakglassPassword
    DELETE /api/v1/auth/breakglass/credentials/{actor_id}            removeBreakglassCredential
    POST   /api/v1/auth/breakglass/credentials/{actor_id}/unlock     unlockBreakglassCredential

  Users cluster (3 ops):
    GET    /api/v1/auth/users                                        listAuthUsers              (auth.user.read)
    DELETE /api/v1/auth/users/{id}                                   deactivateAuthUser         (auth.user.deactivate)
    POST   /api/v1/auth/users/{id}/reactivate                        reactivateAuthUser         (auth.user.deactivate)

  Runtime-config read (1 op):
    GET    /api/v1/auth/runtime-config                               getAuthRuntimeConfig       (auth.role.assign)

5 new schemas (components/schemas)
==================================

  BreakglassCredentialResponse     — mirrors breakglassCredentialResponse
                                     (6 fields). Password hash NEVER
                                     serialized.
  BreakglassCredentialListResponse — mirrors listBreakglassCredentialsResponse
                                     ({"credentials": [...]}).
  BreakglassSetPasswordRequest     — mirrors breakglassSetPasswordRequest
                                     (actor_id + password; password marked
                                     `format: password`).
  BreakglassSetPasswordResponse    — mirrors the inline response shape
                                     returned by SetPassword (actor_id +
                                     created_at).
  AuthUser                         — mirrors userResponse (9 fields,
                                     including pointer-based
                                     deactivated_at marked nullable).

Every schema field's JSON tag, type, required-ness, and (where
applicable) nullability grounded against the live Go source. The
`tenant_id` field surfaces on AuthUser (the handler emits it) but
does NOT appear on the breakglass schemas (the breakglass surface
is tenant-implicit — derived from caller context, not request body).

Surface-invisibility property
=============================

Each break-glass admin endpoint returns 404 when
`CERTCTL_BREAKGLASS_ENABLED=false` so an attacker probing the admin
surface gets the same signal as probing the login endpoint
(consistent with Audit 2026-05-10 CRIT-4 closure). Documented in the
per-op description so client implementations don't surprise on the
404 path.

Self-deactivate guard
=====================

`DELETE /api/v1/auth/users/{id}` returns 409 (not 403) when the
caller is deactivating their own account — Audit 2026-05-11 A-2
foot-gun closure. Break-glass remains the documented recovery path.
The 409 is documented in the per-op responses block.

Exception YAML + baseline
=========================

8 entries removed from api/openapi-handler-exceptions.yaml. Post-cut
shape:

  total entries:           43   (was 51)
  wire-protocol:           36   (unchanged)
  rest-deferred:           7    (was 15)

Baseline file bumped 15 → 7. The Sprint 13.1 monotonic-decrease
guard now pins `rest-deferred ≤ 7`. Sprint 13.6 walks it to zero
(7 → 0).

YAML header narrative updated: "Sprint 13.5 SHIPPED — 15 - 8 = 7".

Receipts (all from the live tree)
=================================

  $ grep -cE '^\s+operationId:' api/openapi.yaml
    179   (was 171 + 8)

  $ bash scripts/ci-guards/openapi-handler-parity.sh
    Router routes:                  220
    OpenAPI operations:             179
    Documented exceptions:          43
      wire-protocol:                36
      rest-deferred:                7
    openapi-handler-parity: clean.

  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    openapi-rest-deferred-monotonic: clean — rest-deferred = 7,
    baseline = 7.

  $ cat api/openapi-handler-exceptions-baseline.txt
    7

  $ python3 -c "import yaml; ..."
    paths: 133, operations: 179, schemas: 72
    sprint-13.5 schemas missing: (none)
    OpenAPI lint: clean.

  $ gofmt -l .                                          → clean
  $ go vet ./internal/api/handler/... ./cmd/server/...  → clean

Sprint 13.6 next (audit/export + demo-residual + 3 OIDC browser
flows + auth/logout + auth/breakglass/login = 7 ops; rest-deferred
7 → 0 — the zero-floor commit that completes ARCH-H1's substantive
burn-down). Same OpenAPI-only pattern; the OIDC browser-flow
endpoints in 13.6 model redirect-only operations (302 + Location
header, empty body) per OAS 3.1 conventions.

Refs: ARCH-H1 batch 2 closure.
This commit is contained in:
shankar0123
2026-05-14 12:28:29 +00:00
parent 952682ebec
commit 9135c44908
3 changed files with 357 additions and 30 deletions
+1 -1
View File
@@ -1 +1 @@
15
7
+6 -29
View File
@@ -41,19 +41,20 @@
# ──────────────────────────────────────────────────────────────────────
#
# Current split, re-derived by the parity script's bucket-reporting
# subcommand (post-Sprint-13.4 / 2026-05-14):
# subcommand (post-Sprint-13.5 / 2026-05-14):
#
# total entries: 51
# total entries: 43
# wire-protocol: 36
# rest-deferred: 15
# rest-deferred: 7
#
# Burn-down progress + remaining plan for the rest-deferred bucket:
#
# Sprint 13.4 SHIPPED — 28 - 13 = 15 (auth/sessions cluster 3 ops +
# auth/oidc CRUD + JWKS + test + refresh
# + group-mappings cluster, 10 ops)
# Sprint 13.5 15 - 8 = 7 (auth/breakglass + auth/users +
# auth/runtime-config, 8 ops)
# Sprint 13.5 SHIPPED — 15 - 8 = 7 (auth/breakglass admin 4 ops +
# auth/users 3 ops + auth/runtime-config
# 1 op, 8 ops total)
# Sprint 13.6 → 7 - 7 = 0 (audit/export + demo-residual + 3
# OIDC browser flows + auth/logout +
# auth/breakglass/login, 7 ops)
@@ -211,30 +212,6 @@ documented_exceptions:
- route: "POST /auth/oidc/back-channel-logout"
why: "Bundle 2 Phase 5 RFC OIDC Back-Channel Logout 1.0 endpoint. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "GET /api/v1/auth/breakglass/credentials"
why: "Bundle 2 Phase 7.5 admin break-glass list (404 when disabled; password hash never on wire)."
category: rest-deferred
- route: "POST /api/v1/auth/breakglass/credentials"
why: "Bundle 2 Phase 7.5 admin break-glass set/rotate password."
category: rest-deferred
- route: "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock"
why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout."
category: rest-deferred
- route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}"
why: "Bundle 2 Phase 7.5 admin break-glass credential delete."
category: rest-deferred
- route: "GET /api/v1/auth/users"
why: "Bundle 2 audit-2026-05-10 MED-11 users page."
category: rest-deferred
- route: "DELETE /api/v1/auth/users/{id}"
why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate."
category: rest-deferred
- route: "POST /api/v1/auth/users/{id}/reactivate"
why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate."
category: rest-deferred
- route: "GET /api/v1/auth/runtime-config"
why: "Bundle 2 audit-2026-05-10 MED-12 effective auth-runtime-config (read-only)."
category: rest-deferred
- route: "POST /api/v1/auth/demo-residual/cleanup"
why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint."
category: rest-deferred
+350
View File
@@ -1024,6 +1024,234 @@ paths:
"404": { description: Mapping not found }
"500": { description: Internal error }
# ════════════════════════════════════════════════════════════════════
# Phase 13 Sprint 13.5 (ARCH-H1 batch 2) — break-glass admin, users
# admin, runtime-config read. Authored 2026-05-14 against the
# internal/api/handler/auth_breakglass.go + auth_users.go handlers.
# Surface-invisibility: every break-glass admin endpoint returns 404
# when CERTCTL_BREAKGLASS_ENABLED=false; documented per-op.
# ════════════════════════════════════════════════════════════════════
/api/v1/auth/breakglass/credentials:
get:
tags: [Auth]
summary: List break-glass credentials (metadata only — never the password hash)
description: |
Permission `auth.breakglass.admin`. Audit 2026-05-10 CRIT-4
closure — backs the GUI Break-glass admin page. The password
hash is NEVER serialized to the wire; only the credential
metadata.
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` (surface-
invisibility: an attacker probing the admin surface gets the
same signal as probing the login endpoint).
operationId: listBreakglassCredentials
responses:
"200":
description: Credential list
content:
application/json:
schema:
$ref: "#/components/schemas/BreakglassCredentialListResponse"
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: Break-glass disabled }
"500": { description: Internal error }
post:
tags: [Auth]
summary: Set a break-glass password for an actor
description: |
Permission `auth.breakglass.admin`. Creates or rotates a
break-glass credential for the named `actor_id`. Password
strength is validated by the service (min 12 bytes, max 256
bytes); weak passwords return 400.
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false`.
operationId: setBreakglassPassword
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BreakglassSetPasswordRequest"
responses:
"201":
description: Credential created or rotated
content:
application/json:
schema:
$ref: "#/components/schemas/BreakglassSetPasswordResponse"
"400": { description: Password fails strength requirements or invalid JSON body }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: Break-glass disabled }
"500": { description: Internal error }
/api/v1/auth/breakglass/credentials/{actor_id}:
delete:
tags: [Auth]
summary: Remove a break-glass credential
description: |
Permission `auth.breakglass.admin`. Returns 404 when
`CERTCTL_BREAKGLASS_ENABLED=false` OR when the credential
doesn't exist.
operationId: removeBreakglassCredential
parameters:
- in: path
name: actor_id
required: true
schema: { type: string }
responses:
"204": { description: Credential removed }
"400": { description: Missing actor_id path param }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: Break-glass disabled OR credential not found }
"500": { description: Internal error }
/api/v1/auth/breakglass/credentials/{actor_id}/unlock:
post:
tags: [Auth]
summary: Clear the lockout on a break-glass credential
description: |
Permission `auth.breakglass.admin`. Resets the failure counter
and clears any active lockout on the named credential so the
actor can attempt to log in again after the configured
lockout window has elapsed organically OR an admin unblocks
them early.
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` OR when
the credential doesn't exist.
operationId: unlockBreakglassCredential
parameters:
- in: path
name: actor_id
required: true
schema: { type: string }
responses:
"204": { description: Lockout cleared }
"400": { description: Missing actor_id path param }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: Break-glass disabled OR credential not found }
"500": { description: Internal error }
/api/v1/auth/users:
get:
tags: [Auth]
summary: List federated users for the active tenant
description: |
Permission `auth.user.read`. Audit 2026-05-10 MED-11 +
2026-05-11 A-2 — backs the admin GUI Users page. Pagination is
not server-side; the repository's `ListAll` returns every row
and the handler filters client-side. Optional
`oidc_provider_id` query parameter scopes the list to users
federated from one provider.
operationId: listAuthUsers
parameters:
- in: query
name: oidc_provider_id
required: false
schema: { type: string }
description: When set, only return users whose `oidc_provider_id` matches exactly.
responses:
"200":
description: User list
content:
application/json:
schema:
type: object
required: [users]
properties:
users:
type: array
items:
$ref: "#/components/schemas/AuthUser"
"401": { description: Unauthorized }
"403": { description: Forbidden }
"500": { description: Internal error }
/api/v1/auth/users/{id}:
delete:
tags: [Auth]
summary: Deactivate a user (sets deactivated_at + cascade-revokes active sessions)
description: |
Permission `auth.user.deactivate`. Audit 2026-05-11 A-2 — the
handler rejects self-deactivation with 409 to prevent the
"admin deactivates self, can't reactivate themselves" foot-gun
(break-glass remains the recovery path).
operationId: deactivateAuthUser
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"204": { description: User deactivated + active sessions revoked }
"400": { description: Missing user id }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: User not found }
"409": { description: Refused — caller is deactivating their own account (use break-glass recovery or have another admin act) }
"500": { description: Internal error }
/api/v1/auth/users/{id}/reactivate:
post:
tags: [Auth]
summary: Reactivate a previously-deactivated user
description: |
Permission `auth.user.deactivate` (same gate as the inverse
op — reactivation is not a separate privilege). Idempotent:
reactivating an already-active user is a no-op (204).
operationId: reactivateAuthUser
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"204": { description: User reactivated (or was already active — idempotent) }
"400": { description: Missing user id }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"404": { description: User not found }
"500": { description: Internal error }
/api/v1/auth/runtime-config:
get:
tags: [Auth]
summary: Read the deployed auth-related runtime configuration
description: |
Permission `auth.role.assign` (admin-class — gated tighter
than `auth.user.read` so non-admins can't enumerate the
deployment's auth knobs). Audit 2026-05-10 MED-12 — backs the
GUI AuthSettings page so operators can verify the deployed
configuration matches their intent from the browser without
SSH access to the host.
Read-only — no mutation surface. Config changes require a
restart + env-var edit by design.
operationId: getAuthRuntimeConfig
responses:
"200":
description: Flat-map view of the relevant CERTCTL_* env vars
content:
application/json:
schema:
type: object
required: [runtime_config]
properties:
runtime_config:
type: object
additionalProperties: { type: string }
description: |
Map of CERTCTL_* env var name → resolved value.
The exact key set depends on the deployment's
configured auth surface (OIDC, break-glass, etc.).
"401": { description: Unauthorized }
"403": { description: Forbidden }
"500": { description: Internal error }
/api/v1/version:
get:
tags: [Health]
@@ -7061,3 +7289,125 @@ components:
type: string
role_id:
type: string
# ════════════════════════════════════════════════════════════════════
# Phase 13 Sprint 13.5 (ARCH-H1 batch 2) schemas. Field-by-field
# mirror of the projection types in
# internal/api/handler/auth_breakglass.go + auth_users.go.
# ════════════════════════════════════════════════════════════════════
BreakglassCredentialResponse:
type: object
description: |
Mirrors internal/api/handler/auth_breakglass.go::
breakglassCredentialResponse. Password hash is NEVER serialized
to the wire — only metadata.
required: [actor_id, created_at, last_password_change_at, failure_count]
properties:
actor_id:
type: string
description: Actor the credential belongs to.
created_at:
type: string
format: date-time
description: RFC 3339 UTC timestamp the credential was first set.
last_password_change_at:
type: string
format: date-time
description: RFC 3339 UTC timestamp the password was most-recently rotated.
failure_count:
type: integer
description: Current consecutive-failure counter (Argon2id lockout state-machine input).
locked_until:
type: string
format: date-time
nullable: true
description: RFC 3339 UTC timestamp past which the lockout clears organically. Omitted when no active lockout.
last_failure_at:
type: string
format: date-time
nullable: true
description: RFC 3339 UTC timestamp of the most recent failed-attempt. Omitted when failure_count == 0.
BreakglassCredentialListResponse:
type: object
description: |
Mirrors internal/api/handler/auth_breakglass.go::
listBreakglassCredentialsResponse.
required: [credentials]
properties:
credentials:
type: array
items:
$ref: "#/components/schemas/BreakglassCredentialResponse"
BreakglassSetPasswordRequest:
type: object
description: |
Mirrors internal/api/handler/auth_breakglass.go::
breakglassSetPasswordRequest. Password is plaintext on the wire
ONLY at set-time; stored at rest as an Argon2id hash with
per-record salt.
required: [actor_id, password]
properties:
actor_id:
type: string
description: Actor the password is being set for.
password:
type: string
format: password
description: New break-glass password. Validated server-side against the strength policy (min 12 bytes, max 256 bytes).
BreakglassSetPasswordResponse:
type: object
description: |
Mirrors the inline response body returned by
AuthBreakglassHandler.SetPassword: actor_id + the credential's
created_at timestamp (RFC 3339, UTC).
required: [actor_id, created_at]
properties:
actor_id:
type: string
created_at:
type: string
format: date-time
description: RFC 3339 UTC timestamp the credential row was created (or re-created on rotation).
AuthUser:
type: object
description: |
Mirrors internal/api/handler/auth_users.go::userResponse. Federated
user shape (OIDC subject + provider). `deactivated_at` is the soft-
delete marker; nil/absent means the user is active.
required: [id, tenant_id, email, display_name, oidc_subject, oidc_provider_id, last_login_at, created_at]
properties:
id:
type: string
description: User identifier (UUID-shaped).
tenant_id:
type: string
email:
type: string
description: Federated email claim from the IdP.
display_name:
type: string
description: Federated display name (preferred_username or name claim from the IdP).
oidc_subject:
type: string
description: The IdP's `sub` claim for this user (stable identifier across email changes).
oidc_provider_id:
type: string
description: ID of the OIDC provider that minted this user record.
last_login_at:
type: string
format: date-time
description: RFC 3339 UTC timestamp of the user's most-recent successful login.
created_at:
type: string
format: date-time
description: RFC 3339 UTC timestamp the user row was first created (upserted from an OIDC callback).
deactivated_at:
type: string
format: date-time
nullable: true
description: RFC 3339 UTC timestamp the user was deactivated. Omitted when the user is active.