From 29cb13e7a26b78efcd4624a4f285388304cc5576 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 12:34:27 +0000 Subject: [PATCH] =?UTF-8?q?docs(arch-h1):=20Phase=2013=20Sprint=2013.6=20?= =?UTF-8?q?=E2=80=94=20OpenAPI=20batch=203=20final=207=20ops;=20rest-defer?= =?UTF-8?q?red=20bucket=20reaches=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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=). 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. --- api/openapi-handler-exceptions-baseline.txt | 2 +- api/openapi-handler-exceptions.yaml | 41 +-- api/openapi.yaml | 341 ++++++++++++++++++++ 3 files changed, 353 insertions(+), 31 deletions(-) diff --git a/api/openapi-handler-exceptions-baseline.txt b/api/openapi-handler-exceptions-baseline.txt index 7f8f011..573541a 100644 --- a/api/openapi-handler-exceptions-baseline.txt +++ b/api/openapi-handler-exceptions-baseline.txt @@ -1 +1 @@ -7 +0 diff --git a/api/openapi-handler-exceptions.yaml b/api/openapi-handler-exceptions.yaml index 8253a4e..4af77af 100644 --- a/api/openapi-handler-exceptions.yaml +++ b/api/openapi-handler-exceptions.yaml @@ -41,13 +41,13 @@ # ────────────────────────────────────────────────────────────────────── # # 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 -# 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 + # auth/oidc CRUD + JWKS + test + refresh @@ -55,13 +55,15 @@ # 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) +# Sprint 13.6 SHIPPED — 7 - 7 = 0 (audit/export 1 op + demo- +# residual/cleanup 1 op + auth/logout 1 op + +# 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 -# 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 # 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 # 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 diff --git a/api/openapi.yaml b/api/openapi.yaml index 815f61c..7aaabff 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1217,6 +1217,313 @@ paths: "404": { description: User not found } "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-_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=` + — 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=` on validation failure + headers: + Location: + description: Either the configured `postLoginURL` (success) or `/login?error=oidc_failed&reason=` (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: get: tags: [Auth] @@ -7411,3 +7718,37 @@ components: format: date-time nullable: true 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).