docs(arch-h1): Phase 13 Sprint 13.6 — OpenAPI batch 3 final 7 ops; rest-deferred bucket reaches 0

Phase 13 Sprint 13.6 — the FINAL ARCH-H1 OpenAPI authoring batch.
Closes the substantive burn-down: rest-deferred bucket reaches 0;
every REST-shaped router route is now authored into openapi.yaml.
Documented exceptions are exclusively wire-protocol contracts (SCEP
RFC 8894, ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030).

Sprint 13.7 next (closure / audit-HTML flip) tightens this commit's
floor: the rest-deferred bucket pin in
openapi-rest-deferred-monotonic.sh changes from
"monotonic-decrease vs baseline" to "hard zero-exact" so a future
PR adding a REST route MUST author its OpenAPI op or fail CI — the
`category: rest-deferred` escape hatch closes for good.

7 new operations (the final batch)
==================================

  One-off REST endpoints (4 ops):
    GET    /api/v1/audit/export                              exportAudit                       (audit.export — NDJSON stream)
    POST   /api/v1/auth/demo-residual/cleanup                cleanupDemoResidualGrants         (auth.role.assign; 503 in demo mode)
    POST   /auth/logout                                      logoutCurrentSession              (auth-exempt; cookie checked inside)
    POST   /auth/breakglass/login                            breakglassLogin                   (auth-bypass; 404 when disabled; rate-limited)

  OIDC browser-flow endpoints (3 ops, modeled as 302+Location-header
  redirects per OAS 3.1 — `responses.302` + `headers.Location` +
  description noting the server-initiated redirect contract; empty
  content block; consumers must follow the redirect for the flow to
  complete):
    GET    /auth/oidc/login                                  oidcLoginInitiate                 (auth-exempt; 302 → IdP authz URL + pre-login cookie)
    GET    /auth/oidc/callback                               oidcLoginCallback                 (auth-exempt; 302 → postLoginURL on success / 302 → /login?error=oidc_failed&reason=<cat> on failure)
    POST   /auth/oidc/back-channel-logout                    oidcBackChannelLogout             (auth via IdP-signed logout_token; 200 + Cache-Control: no-store on success; uniform 400 per spec §2.6 on failure)

The 4 one-off REST endpoints model standard JSON contracts. The 3
OIDC browser-flow endpoints DELIBERATELY model the 302-with-Location
contract because that's the live wire shape — modeling them as
200-with-JSON would lie about reality (and break any generated
client that assumes a JSON response body). Each `headers.Location`
is documented with the actual redirect target shape (provider authz
URL / postLoginURL / /login?error=oidc_failed&reason=<category>).

Audit/export NDJSON streaming
=============================

The audit/export response is `application/x-ndjson` — one JSON-
encoded AuditEvent per line, NOT a single JSON document. Documented
explicitly so generated clients know to parse line-by-line. Schema
references the existing #/components/schemas/AuditEvent (already
defined as part of the audit-events surface).

Range cap + per-record cap + filter shape all documented in the
parameters block (90-day max window, 1..100000 limit, category enum
of cert_lifecycle/auth/config).

2 new schemas (components/schemas)
==================================

  DemoResidualCleanupResponse  — mirrors demoResidualCleanupResponse
                                 ({removed: int64}).
  BreakglassLoginRequest       — mirrors breakglassLoginRequest
                                 (actor_id + password; password
                                 marked `format: password`).

Pre-existing AuditEvent + BreakglassLoginRequest-adjacent schemas
(Sprint 13.4 + 13.5) are referenced via $ref without duplication.

Exception YAML + baseline + zero-floor pin
==========================================

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

  total entries:           36
  wire-protocol:           36   (unchanged — these never burn down)
  rest-deferred:           0    ← THE FLOOR

Baseline file bumped 7 → 0. The Sprint 13.1 monotonic-decrease
guard now pins `rest-deferred ≤ 0` — equivalent to "the bucket
must stay empty." Sprint 13.7 will additionally tighten the
parity-script's missing-category check so the bucket can't be
re-grown via the `category:` typo escape hatch either.

YAML header narrative updated: "Sprint 13.6 SHIPPED — 7 - 7 = 0".
ARCH-H1 substantive close achieved at the bucket-math level.

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

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

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

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

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

  $ python3 -c "import yaml; ..."
    paths: 140, operations: 186, schemas: 74
    sprint-13.6 schemas missing: (none)
    OpenAPI lint: clean.

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

ARCH-H1 final tally (across Sprints 13.1 + 13.4 + 13.5 + 13.6)
==============================================================

  Sprint 13.1: structural categorization — split 64 exceptions into
               36 wire-protocol + 28 rest-deferred; added parity-
               script bucket reporting + monotonic-decrease guard +
               baseline file. ARCH-H1's structural close.

  Sprint 13.4: 13 OpenAPI ops + 13 exception deletions + baseline
               28 → 15. Auth/sessions + OIDC CRUD/JWKS/test/refresh
               + group-mappings clusters.

  Sprint 13.5: 8 OpenAPI ops + 8 exception deletions + baseline
               15 → 7. Auth/breakglass + auth/users +
               auth/runtime-config clusters.

  Sprint 13.6 (this commit): 7 OpenAPI ops + 7 exception deletions
               + baseline 7 → 0. Audit/export + demo-residual +
               auth/logout + auth/breakglass/login + 3 OIDC browser
               flows. ARCH-H1's substantive close.

  Cumulative: 28 OpenAPI ops authored, 28 exception entries deleted,
  rest-deferred bucket drained from 28 → 0. The OpenAPI surface
  exactly matches every REST-shaped router route.

Sprint 13.7 closes the audit HTML flip + tightens this commit's
monotonic-decrease floor to a zero-exact pin so the burn-down is
locked.

Refs: ARCH-H1 substantive close — final batch.
This commit is contained in:
shankar0123
2026-05-14 12:34:27 +00:00
parent 9135c44908
commit 29cb13e7a2
3 changed files with 353 additions and 31 deletions
+1 -1
View File
@@ -1 +1 @@
7 0
+11 -30
View File
@@ -41,13 +41,13 @@
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
# #
# Current split, re-derived by the parity script's bucket-reporting # Current split, re-derived by the parity script's bucket-reporting
# subcommand (post-Sprint-13.5 / 2026-05-14): # subcommand (post-Sprint-13.6 / 2026-05-14):
# #
# total entries: 43 # total entries: 36
# wire-protocol: 36 # wire-protocol: 36
# rest-deferred: 7 # rest-deferred: 0 ← THE FLOOR — ARCH-H1 substantive close
# #
# Burn-down progress + remaining plan for the rest-deferred bucket: # Burn-down progress:
# #
# Sprint 13.4 SHIPPED — 28 - 13 = 15 (auth/sessions cluster 3 ops + # Sprint 13.4 SHIPPED — 28 - 13 = 15 (auth/sessions cluster 3 ops +
# auth/oidc CRUD + JWKS + test + refresh # auth/oidc CRUD + JWKS + test + refresh
@@ -55,13 +55,15 @@
# Sprint 13.5 SHIPPED — 15 - 8 = 7 (auth/breakglass admin 4 ops + # Sprint 13.5 SHIPPED — 15 - 8 = 7 (auth/breakglass admin 4 ops +
# auth/users 3 ops + auth/runtime-config # auth/users 3 ops + auth/runtime-config
# 1 op, 8 ops total) # 1 op, 8 ops total)
# Sprint 13.6 7 - 7 = 0 (audit/export + demo-residual + 3 # Sprint 13.6 SHIPPED — 7 - 7 = 0 (audit/export 1 op + demo-
# OIDC browser flows + auth/logout + # residual/cleanup 1 op + auth/logout 1 op +
# auth/breakglass/login, 7 ops) # auth/breakglass/login 1 op + 3 OIDC
# browser-flow endpoints, 7 ops total)
# #
# Sprint 13.7 then tightens the parity-script's rest-deferred floor # Sprint 13.7 next tightens the parity-script's rest-deferred floor
# from monotonic-decrease to a hard zero-exact pin. After that, any # from monotonic-decrease to a hard zero-exact pin. After that, any
# new REST route MUST land with an OpenAPI op or fail CI. # new REST route MUST land with an OpenAPI op or fail CI — no escape
# hatch via `category: rest-deferred`.
# #
# Each authored OpenAPI op needs request/response schemas (not # Each authored OpenAPI op needs request/response schemas (not
# placeholders) so the generated client at web/orval.config.ts emits # placeholders) so the generated client at web/orval.config.ts emits
@@ -197,24 +199,3 @@ documented_exceptions:
# stays green for the v2.1.0 release tag. Threat model + handler contracts # stays green for the v2.1.0 release tag. Threat model + handler contracts
# live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}. # live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}.
# ============================================================================= # =============================================================================
- route: "GET /auth/oidc/login"
why: "Bundle 2 Phase 5 OIDC login redirect; user-facing 302 with state cookie. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "GET /auth/oidc/callback"
why: "Bundle 2 Phase 5 OIDC callback handler; RFC 9700 §4.7.1 + RFC 9207. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "POST /auth/logout"
why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "POST /auth/breakglass/login"
why: "Bundle 2 Phase 7.5 public break-glass login (auth-bypass, 404 when disabled). OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- 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: "POST /api/v1/auth/demo-residual/cleanup"
why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint."
category: rest-deferred
- route: "GET /api/v1/audit/export"
why: "Bundle 1 Phase 8 streaming NDJSON audit export."
category: rest-deferred
+341
View File
@@ -1217,6 +1217,313 @@ paths:
"404": { description: User not found } "404": { description: User not found }
"500": { description: Internal error } "500": { description: Internal error }
# ════════════════════════════════════════════════════════════════════
# Phase 13 Sprint 13.6 (ARCH-H1 batch 3 — the final batch).
# 4 one-off REST endpoints + 3 OIDC browser-flow endpoints; rest-
# deferred bucket goes from 7 → 0 with this commit. Browser-flow
# endpoints model the 302+Location-header redirect contract per
# OAS 3.1; consumers know they are NOT standard JSON responses.
# ════════════════════════════════════════════════════════════════════
/api/v1/audit/export:
get:
tags: [Audit]
summary: Export audit events as newline-delimited JSON (NDJSON) for a date range
description: |
Permission `audit.export`. Streams every audit row inside the
requested `[from, to]` window as `application/x-ndjson`. Used
by compliance pipelines (Splunk Universal Forwarder, Elastic
Filebeat, Vector, etc.) that prefer line-by-line ingestion
over a single JSON document.
Range cap: 90 days. Requests with `to - from > 90d` return
400; paginate by narrower windows.
Per-record cap: `limit` query parameter (default 50000;
accepted range 1..100000). Values outside the range silently
clamp to default.
The export itself is recursively audited: every successful
export emits an `audit.export` event capturing actor, range,
category, and row count so the audit log records who pulled
which compliance evidence and when.
operationId: exportAudit
parameters:
- in: query
name: from
required: true
schema: { type: string, format: date-time }
description: RFC 3339 start of the export window (inclusive).
- in: query
name: to
required: true
schema: { type: string, format: date-time }
description: RFC 3339 end of the export window (exclusive). Must be strictly after `from`.
- in: query
name: category
required: false
schema:
type: string
enum: [cert_lifecycle, auth, config]
description: Optional category filter. Omit to return every event in the window.
- in: query
name: limit
required: false
schema: { type: integer, minimum: 1, maximum: 100000 }
description: Maximum rows to stream (default 50000; out-of-range values clamp to default).
responses:
"200":
description: NDJSON stream of AuditEvent rows
headers:
Content-Disposition:
description: Attachment hint suggesting filename `certctl-audit-<from>_to_<to>.ndjson`.
schema: { type: string }
content:
application/x-ndjson:
schema:
type: string
description: |
One JSON-encoded AuditEvent per line (see
`#/components/schemas/AuditEvent`). The body is a
byte stream, not a single JSON document; clients
parse line-by-line.
"400": { description: Missing/invalid date params, range exceeds 90 days, invalid category, etc. }
"401": { description: Unauthorized }
"403": { description: Forbidden }
"405": { description: Method not allowed (only GET is accepted) }
"500": { description: Internal error (export failed mid-query) }
/api/v1/auth/demo-residual/cleanup:
post:
tags: [Auth]
summary: Remove residual `actor-demo-anon` role grants after exiting demo mode
description: |
Permission `auth.role.assign` (admin-class). Removes the
leftover `actor_roles` rows for the synthetic `actor-demo-anon`
actor after the operator has flipped `CERTCTL_AUTH_TYPE` away
from `none` (demo mode). Idempotent: subsequent invocations
return `removed: 0`.
Refuses (503) when the server is currently in demo mode —
the `actor-demo-anon` grants ARE the active runtime state at
`auth_type=none`, so "cleaning them up" would lock the operator
out of the live admin surface. The GUI hides the action button
when `/api/v1/auth/info` reports `auth_type=none`; this guard
is defense-in-depth.
operationId: cleanupDemoResidualGrants
responses:
"200":
description: Cleanup complete (count returned)
content:
application/json:
schema:
$ref: "#/components/schemas/DemoResidualCleanupResponse"
"401": { description: Unauthorized }
"403": { description: Forbidden }
"500": { description: Internal error (cleanup not configured or failed mid-delete) }
"503": { description: Refused — server is currently in demo mode (CERTCTL_AUTH_TYPE=none) }
/auth/logout:
post:
tags: [Auth]
summary: Revoke the caller's current session
description: |
Auth-exempt at the router (the session cookie is validated
inside the handler). Reads `certctl_session` cookie, validates
+ revokes the underlying session row, rotates the CSRF token
on the actor's other sessions (Audit 2026-05-11 Fix 13 / HIGH-2
fourth call site), clears the session + CSRF cookies, returns
204.
Idempotent: when no valid session cookie is presented the
handler clears any stale cookies and returns 204 without
side-effects.
operationId: logoutCurrentSession
security: []
responses:
"204": { description: Session revoked (or none to revoke — idempotent) }
"500": { description: Internal error during revoke }
/auth/breakglass/login:
post:
tags: [Auth]
summary: Local password login for emergency admin recovery
description: |
Auth-bypass — the whole point is to log in WITHOUT existing
certctl credentials. On success, sets the post-login session
cookie + CSRF cookie and returns 204. On any failure (wrong
password, locked account, no credential, unknown actor):
uniform 401 + identical timing (no scanner-friendly
distinction).
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` (surface
invisibility — Phase 7.5 spec).
Rate-limited per source IP (default 5 attempts/min/IP;
configurable via `CERTCTL_RATE_LIMIT_BACKEND` + the
constructor in cmd/server/main.go). Exceeded budget returns
429 with no body.
operationId: breakglassLogin
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BreakglassLoginRequest"
responses:
"204":
description: Authenticated — Set-Cookie carries the new session + CSRF tokens
headers:
Set-Cookie:
description: |
Two `Set-Cookie` headers are emitted on success: the
post-login session cookie (`certctl_session`, HttpOnly)
and the CSRF token cookie (`certctl_csrf`, NOT HttpOnly
so the GUI can read it to echo in `X-CSRF-Token`).
schema: { type: string }
"401": { description: Authentication failed (uniform — covers wrong password, missing credential, unknown actor; identical timing) }
"404": { description: Break-glass disabled (CERTCTL_BREAKGLASS_ENABLED=false) }
"429": { description: Rate limit exceeded (per source IP) }
"500": { description: Internal error during authenticate }
/auth/oidc/login:
get:
tags: [Auth]
summary: Browser-flow — start an OIDC login; 302 to IdP authorization URL
description: |
Auth-exempt — pre-auth by definition. Browser-flow endpoint:
the response is a 302 with `Location:` pointing at the
configured provider's authorization URL, NOT a JSON response.
Consumers MUST follow the redirect for the flow to complete.
On success: persists a pre-login row capturing the state +
nonce + PKCE-S256 verifier, sets the `certctl_oidc_pending`
cookie (HttpOnly, SameSite=Lax, 10-minute lifetime, `__Host-`
prefix), and 302-redirects to the IdP. The cookie is consumed
+ cleared by `/auth/oidc/callback`.
Audit 2026-05-10 MED-16 — the pre-login row captures the
client IP + User-Agent at this step so the callback handler
can reject a stolen cookie replayed from a different browser
or source.
operationId: oidcLoginInitiate
security: []
parameters:
- in: query
name: provider
required: true
schema: { type: string }
description: OIDC provider ID (one of the rows under `/api/v1/auth/oidc/providers`).
responses:
"302":
description: Redirect to the IdP's authorization URL
headers:
Location:
description: IdP authorization URL (provider-specific, includes state + nonce + PKCE challenge).
schema: { type: string, format: uri }
Set-Cookie:
description: The `certctl_oidc_pending` pre-login cookie.
schema: { type: string }
"400": { description: Missing `provider` query parameter }
"404": { description: Provider not found }
"500": { description: IdP discovery fetch failed, downgrade-defense tripped, or internal crypto failure }
/auth/oidc/callback:
get:
tags: [Auth]
summary: Browser-flow — consume IdP authorization response; 302 to post-login URL
description: |
Auth-exempt — pre-auth by definition (the cookie + state are
validated inside the handler). Browser-flow endpoint: the
success response is a 302 with `Location:` pointing at the
configured `postLoginURL` (default `/`), NOT a JSON response.
Reads the `certctl_oidc_pending` pre-login cookie, drives the
OIDC service's 11-step token validation (sig + iss + aud +
nonce + at_hash + iat + jti + sub + group-claim resolution +
role-mapping + user-upsert), mints a post-login session,
deletes the pre-login cookie, sets the post-login session +
CSRF cookies, and 302's to the dashboard.
Failure responses are ALSO 302 — to `/login?error=oidc_failed&reason=<category>`
— so the SPA can render an operator-friendly alert without
the browser seeing a raw 400. The audit row carries the
specific failure category server-side.
operationId: oidcLoginCallback
security: []
parameters:
- in: query
name: code
required: true
schema: { type: string }
description: OAuth2 authorization code returned by the IdP.
- in: query
name: state
required: true
schema: { type: string }
description: Opaque state value the certctl `/auth/oidc/login` step embedded into the IdP URL.
- in: query
name: iss
required: false
schema: { type: string }
description: RFC 9207 `iss` URL parameter. Preserved byte-strict for the service-layer compare against the matched provider's `IssuerURL`. The IdP emits this only when advertised in its discovery doc; the service-layer check is a no-op otherwise.
responses:
"302":
description: Redirect — either to `postLoginURL` on success OR to `/login?error=oidc_failed&reason=<category>` on validation failure
headers:
Location:
description: Either the configured `postLoginURL` (success) or `/login?error=oidc_failed&reason=<category>` (failure).
schema: { type: string, format: uri-reference }
Set-Cookie:
description: On success — two `Set-Cookie` headers carrying the post-login session + CSRF tokens. On failure — a single `Set-Cookie` clearing the pre-login cookie.
schema: { type: string }
"400": { description: Missing `code`/`state` query parameter, or missing pre-login cookie }
/auth/oidc/back-channel-logout:
post:
tags: [Auth]
summary: IdP-initiated session revocation via OIDC Back-Channel Logout 1.0
description: |
Auth via the IdP-signed `logout_token` JWT in the form body —
NOT certctl-issued credentials, so `security: []`. Spec
reference: OpenID Connect Back-Channel Logout 1.0 §2.6.
Validates the logout token against the matched provider's
JWKS (signature + alg-allowlist + iat-skew window + jti
consumed-set + required claims: iss, aud, iat, jti, events;
exactly one of sub or sid; nonce MUST be absent), revokes
every matching session, returns 200 with `Cache-Control:
no-store`.
Any validation failure returns 400 — uniform wire shape per
spec §2.6. The audit row carries the specific reason.
Replayed jti (RFC 9700 §2.7) returns 200 with audit
outcome=jti_replayed (idempotent re-receive of a logout
is harmless).
operationId: oidcBackChannelLogout
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [logout_token]
properties:
logout_token:
type: string
description: IdP-signed logout_token JWT per OpenID Connect Back-Channel Logout 1.0 §2.4.
responses:
"200":
description: Logout token accepted; matching sessions revoked
headers:
Cache-Control:
description: Always `no-store` per spec.
schema: { type: string, enum: [no-store] }
"400": { description: logout_token validation failed (signature, iss, aud, iat-window, jti consumed-set, required claims, etc.) — uniform per spec §2.6 }
"503": { description: Transient repository error on jti consumed-set write — IdP should retry per its own semantics }
/api/v1/auth/runtime-config: /api/v1/auth/runtime-config:
get: get:
tags: [Auth] tags: [Auth]
@@ -7411,3 +7718,37 @@ components:
format: date-time format: date-time
nullable: true nullable: true
description: RFC 3339 UTC timestamp the user was deactivated. Omitted when the user is active. description: RFC 3339 UTC timestamp the user was deactivated. Omitted when the user is active.
# ════════════════════════════════════════════════════════════════════
# Phase 13 Sprint 13.6 (ARCH-H1 batch 3 — final batch) schemas.
# ════════════════════════════════════════════════════════════════════
DemoResidualCleanupResponse:
type: object
description: |
Mirrors internal/api/handler/demo_residual.go::
demoResidualCleanupResponse. Always present; idempotent re-runs
return `removed: 0`.
required: [removed]
properties:
removed:
type: integer
format: int64
description: Number of `actor_roles` rows removed in this cleanup call.
BreakglassLoginRequest:
type: object
description: |
Mirrors internal/api/handler/auth_breakglass.go::
breakglassLoginRequest. Plaintext password on the wire ONLY at
login-time; the service hashes via Argon2id for the
constant-time compare.
required: [actor_id, password]
properties:
actor_id:
type: string
description: Actor attempting recovery login.
password:
type: string
format: password
description: Plaintext password (Argon2id-hashed at rest by the service).