Files
certctl/scripts/ci-guards/openapi-handler-parity.sh
T
shankar0123 67f346cd87 docs(arch-h1): Phase 13 Sprint 13.1 — categorize OpenAPI exceptions + bucket guards
Phase 13 Sprint 13.1 closure (architecture diligence audit ARCH-H1):
splits api/openapi-handler-exceptions.yaml's 64 entries into two
buckets via a required `category:` field, extends the parity script
with bucket reporting + a `--bucket=` subcommand, and adds a sibling
monotonic-decrease guard pinned to a checked-in baseline file. Pure
YAML + bash + doc; zero runtime change.

Strategy
========
The audit originally framed ARCH-H1 as "burn down the 64-entry
exception list to ≤20." Sprint 13.1 reframes against the structural
reality: 36 of the 64 entries are legitimate IETF-RFC wire-protocol
contracts (SCEP RFC 8894, ACME RFC 8555, ACME ARI RFC 9773, EST
RFC 7030) that MUST stay; the remaining 28 are REST-shaped routes
whose OpenAPI op was deferred. Categorize the two buckets, monotone-
gate the rest-deferred bucket against a baseline, and Sprints
13.4-13.6 drive rest-deferred to zero.

Categorization rule applied per-entry
=====================================
An entry is `category: wire-protocol` if ANY of:
  1. `why:` cites an RFC anchor (RFC 8894 / 8555 / 9773 / 7030).
  2. `why:` contains the strings "wire-protocol", "wire protocol",
     "sibling", or "shorthand".
  3. Route path starts with `/scep`, `/scep-mtls`, `/acme/`, or
     `/acme` (wire-protocol prefix).
Otherwise: `category: rest-deferred`.

This rule produced the 36 / 28 split that the Sprint 13.1 audit
prompt expected — verified by python assertion + manual eyeball
review of every entry's `why:` field before categorizing.

Per-entry decisions (read off the post-categorization YAML)
===========================================================

WIRE-PROTOCOL (36) — RFC contracts; never burn down:

  SCEP family (8) — RFC 8894 + RFC 7030 SCEP-mTLS sibling:
    GET    /scep                  RFC 8894 §3.1 GetCACert / GetCACaps
    POST   /scep                  RFC 8894 §3.1 PKCSReq / RenewalReq
    GET    /scep/                 trailing-slash variant (ChromeOS)
    POST   /scep/                 trailing-slash variant (ChromeOS)
    GET    /scep-mtls             EST RFC 7030 Phase 6.5 sibling
    POST   /scep-mtls             SCEP-mTLS POST variant
    GET    /scep-mtls/            SCEP-mTLS trailing-slash variant
    POST   /scep-mtls/            SCEP-mTLS trailing-slash POST

  ACME per-profile (12) — RFC 8555 §7.x + RFC 9773 ARI:
    GET    /acme/profile/{id}/directory             RFC 8555 §7.1.1
    HEAD   /acme/profile/{id}/new-nonce             RFC 8555 §7.2
    GET    /acme/profile/{id}/new-nonce             RFC 8555 §7.2
    POST   /acme/profile/{id}/new-account           RFC 8555 §7.3
    POST   /acme/profile/{id}/account/{acc_id}      RFC 8555 §7.3.2/.6
    POST   /acme/profile/{id}/new-order             RFC 8555 §7.4
    POST   /acme/profile/{id}/order/{ord_id}        RFC 8555 §7.4 PoG
    POST   /acme/profile/{id}/order/{ord_id}/finalize  RFC 8555 §7.4
    POST   /acme/profile/{id}/authz/{authz_id}      RFC 8555 §7.5
    POST   /acme/profile/{id}/challenge/{chall_id}  RFC 8555 §7.5.1
    POST   /acme/profile/{id}/cert/{cert_id}        RFC 8555 §7.4.2
    POST   /acme/profile/{id}/key-change            RFC 8555 §7.3.5
    POST   /acme/profile/{id}/revoke-cert           RFC 8555 §7.6
    GET    /acme/profile/{id}/renewal-info/{cert_id} RFC 9773 ARI

  ACME default-profile shorthand (14) — sibling routes; same wire
  semantics, dispatched when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID
  is set:
    GET    /acme/directory
    HEAD   /acme/new-nonce
    GET    /acme/new-nonce
    POST   /acme/new-account
    POST   /acme/account/{acc_id}
    POST   /acme/new-order
    POST   /acme/order/{ord_id}
    POST   /acme/order/{ord_id}/finalize
    POST   /acme/authz/{authz_id}
    POST   /acme/challenge/{chall_id}
    POST   /acme/cert/{cert_id}
    POST   /acme/key-change
    POST   /acme/revoke-cert
    GET    /acme/renewal-info/{cert_id}

REST-DEFERRED (28) — gaps; Sprints 13.4-13.6 author into openapi.yaml:

  auth/sessions cluster (3):
    GET    /api/v1/auth/sessions
    DELETE /api/v1/auth/sessions
    DELETE /api/v1/auth/sessions/{id}

  auth/oidc CRUD + JWKS + test + refresh cluster (10):
    GET    /api/v1/auth/oidc/providers
    POST   /api/v1/auth/oidc/providers
    PUT    /api/v1/auth/oidc/providers/{id}
    DELETE /api/v1/auth/oidc/providers/{id}
    GET    /api/v1/auth/oidc/providers/{id}/jwks-status
    POST   /api/v1/auth/oidc/providers/{id}/refresh
    POST   /api/v1/auth/oidc/test
    GET    /api/v1/auth/oidc/group-mappings
    POST   /api/v1/auth/oidc/group-mappings
    DELETE /api/v1/auth/oidc/group-mappings/{id}

  auth/breakglass admin cluster (4):
    GET    /api/v1/auth/breakglass/credentials
    POST   /api/v1/auth/breakglass/credentials
    DELETE /api/v1/auth/breakglass/credentials/{actor_id}
    POST   /api/v1/auth/breakglass/credentials/{actor_id}/unlock

  auth/users cluster (3):
    GET    /api/v1/auth/users
    DELETE /api/v1/auth/users/{id}
    POST   /api/v1/auth/users/{id}/reactivate

  Misc REST one-offs (3):
    GET    /api/v1/auth/runtime-config
    POST   /api/v1/auth/demo-residual/cleanup
    GET    /api/v1/audit/export

  OIDC + breakglass browser flows (5):
    GET    /auth/oidc/login
    GET    /auth/oidc/callback
    POST   /auth/oidc/back-channel-logout
    POST   /auth/logout
    POST   /auth/breakglass/login

Files changed
=============

api/openapi-handler-exceptions.yaml (+1 line per entry):
  - Header rewritten to document the two-bucket contract + the
    Phase 13 burn-down plan + the baseline-file convention.
  - Every existing `route:` + `why:` pair preserved verbatim.
  - `    category: <bucket>` line inserted after each `why:` line.
  - Pyyaml round-trip parses to 64 entries cleanly.

api/openapi-handler-exceptions-baseline.txt (NEW, 1 line):
  - Contains single integer `28` matching the current rest-deferred
    count. Sprints 13.4-13.6 decrement this in lockstep with each
    batch of OpenAPI ops authored.

scripts/ci-guards/openapi-handler-parity.sh (rewritten):
  - Reports `wire-protocol: N` + `rest-deferred: N` lines alongside
    the existing total.
  - New `--bucket=wire-protocol|rest-deferred` subcommand prints
    just the bucket count + exits 0. Used by the new monotonic
    guard + by Sprint 13.7's hard-floor pin.
  - New fail condition: any entry missing the required `category:`
    field, or carrying an unknown category value, fails the build
    with a clear ::error:: annotation.
  - Existing exit-code semantics preserved (drift / orphan / stale
    detection paths unchanged).

scripts/ci-guards/openapi-rest-deferred-monotonic.sh (NEW):
  - Reads the rest-deferred count via the parity script's --bucket
    subcommand.
  - Reads the baseline file at
    api/openapi-handler-exceptions-baseline.txt.
  - Fails with ::error:: if current count exceeds OR falls below the
    baseline. The fall-below path forces operators to update the
    baseline in the same commit as the corresponding YAML deletion
    — keeps the monotonic-decrease contract honest.
  - CI workflow auto-discovers any scripts/ci-guards/*.sh; no
    .github/workflows/ci.yml change required (verified — the loop
    at .github/workflows/ci.yml::Regression\ guards uses a glob).

scripts/ci-guards/README.md (+33 lines):
  - Two new entries in the per-finding regression-guards table for
    `openapi-handler-parity` (existing; bucket subcommand documented)
    and `openapi-rest-deferred-monotonic` (new).
  - New "ARCH-H1 OpenAPI exception two-bucket contract" section
    documenting the wire-protocol vs rest-deferred decision rule +
    the canonical close path for a rest-deferred entry (author op
    + delete exception + decrement baseline in same PR) + the
    bucket-count inspection commands.

Verification (all local, sandbox /sessions partition full so
disk-tmpfile-dependent guards skipped — see Hotfix #4 commit msg
for sandbox-disk context)
=========================================================

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

  $ bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
    36

  $ bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
    28

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

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

  $ python3 -c "import yaml; d=yaml.safe_load(open('api/openapi-handler-exceptions.yaml')); print(len(d['documented_exceptions']))"
    64

Negative test (corrupted baseline → guard fails):
  $ echo "abc" > api/openapi-handler-exceptions-baseline.txt
  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    ::error::api/openapi-handler-exceptions-baseline.txt must contain
    a single non-negative integer; got: 'abc'

Negative test (rest-deferred over baseline → guard fails):
  $ echo "27" > api/openapi-handler-exceptions-baseline.txt
  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    ::error::rest-deferred bucket grew: 28 > baseline 27.

Negative test (missing category → parity script fails):
  $ # delete first 'category: wire-protocol' line
  $ bash scripts/ci-guards/openapi-handler-parity.sh
    ::error::api/openapi-handler-exceptions.yaml: 1 entries missing
    required `category:` field:
      GET /scep

Ambiguous entries surfaced for operator review
==============================================
None. Every entry's category derived deterministically from the
3-rule decision tree (RFC anchor → wire-protocol; wire/sibling/
shorthand keyword in `why:` → wire-protocol; route prefix matches
wire-protocol family → wire-protocol; otherwise rest-deferred).

Closes: Phase 13 Sprint 13.1 of the certctl architecture diligence
remediation (ARCH-H1 structural categorization). Unblocks Sprints
13.4-13.6 (OpenAPI authoring batches against the rest-deferred
bucket).
2026-05-14 11:18:12 +00:00

174 lines
6.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# scripts/ci-guards/openapi-handler-parity.sh
#
# Verify every router.Register / r.mux.Handle call has a matching
# operationId in api/openapi.yaml, modulo documented exceptions in
# api/openapi-handler-exceptions.yaml.
#
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
#
# Phase 13 Sprint 13.1 (2026-05-14) — every entry in the exceptions
# YAML now carries a required `category: wire-protocol | rest-deferred`
# field. This script reports the two buckets alongside the total. The
# rest-deferred bucket is gated by a sibling guard
# (openapi-rest-deferred-monotonic.sh) against a checked-in baseline
# at api/openapi-handler-exceptions-baseline.txt.
#
# Current state (2026-05-14):
# 220 r.Register / r.mux.Handle call sites in internal/api/router/router.go
# 158 operationIds in api/openapi.yaml
# 64 documented exceptions (36 wire-protocol + 28 rest-deferred)
# 0 unaccounted router routes — guard passes clean today.
#
# Sprints 13.4-13.6 drive rest-deferred to zero by authoring OpenAPI ops
# for the 28 REST-shaped routes; each batch deletes the corresponding
# exception entries + bumps the baseline file downward. Sprint 13.7
# tightens this guard's rest-deferred floor from "monotonic-decrease"
# (sibling guard) to a hard zero-exact pin (this guard).
#
# Going forward: any new gap (in either direction) fails the build
# unless documented in the exceptions YAML with a category.
#
# Subcommand:
# bash scripts/ci-guards/openapi-handler-parity.sh
# Full parity check + bucket reporting.
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
# Print just the count for the named bucket (used by sibling guards
# + Sprint 13.7's zero-exact pin). Exit 0 always; informational.
set -e
BUCKET=""
case "${1:-}" in
--bucket=wire-protocol|--bucket=rest-deferred)
BUCKET="${1#--bucket=}"
;;
"")
;;
*)
echo "::error::unknown argument: $1"
echo "usage: $0 [--bucket=wire-protocol|--bucket=rest-deferred]"
exit 2
;;
esac
python3 - "$BUCKET" <<'PY'
import re, sys, yaml
bucket_arg = sys.argv[1] if len(sys.argv) > 1 else ""
# Extract router routes: r.mux.Handle("METHOD /path", ...) and
# r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax.
with open('internal/api/router/router.go') as f:
src = f.read()
routes = []
for m in re.finditer(r'r\.(?:mux\.Handle|Register)\("([A-Z]+)\s+(/[^"]*)"', src):
routes.append((m.group(1), m.group(2)))
router_set = set(routes)
# Extract OpenAPI operations: paths × HTTP methods
with open('api/openapi.yaml') as f:
spec = yaml.safe_load(f)
oapi_set = set()
for path, methods in (spec.get('paths') or {}).items():
for method, op in methods.items():
if method.upper() in ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'):
oapi_set.add((method.upper(), path))
# Extract documented exceptions
try:
with open('api/openapi-handler-exceptions.yaml') as f:
exc_doc = yaml.safe_load(f)
except FileNotFoundError:
exc_doc = {'documented_exceptions': []}
exception_set = set()
bucket_counts = {'wire-protocol': 0, 'rest-deferred': 0}
missing_category = []
unknown_category = []
for entry in (exc_doc.get('documented_exceptions') or []):
route_str = entry['route']
parts = route_str.split(maxsplit=1)
if len(parts) == 2:
exception_set.add((parts[0], parts[1]))
cat = entry.get('category')
if cat is None:
missing_category.append(route_str)
elif cat in bucket_counts:
bucket_counts[cat] += 1
else:
unknown_category.append((route_str, cat))
# --bucket=X subcommand: print just the count, exit 0, no other output.
if bucket_arg in bucket_counts:
print(bucket_counts[bucket_arg])
sys.exit(0)
# Report counts
print(f"Router routes: {len(router_set)}")
print(f"OpenAPI operations: {len(oapi_set)}")
print(f"Documented exceptions: {len(exception_set)}")
print(f" wire-protocol: {bucket_counts['wire-protocol']}")
print(f" rest-deferred: {bucket_counts['rest-deferred']}")
print()
fail = False
# Phase 13 Sprint 13.1: every entry MUST have a category. Missing or
# unknown categories fail the build — keeps the bucket math honest.
if missing_category:
print(f"::error::api/openapi-handler-exceptions.yaml: {len(missing_category)} entries missing required `category:` field:")
for r in missing_category:
print(f" {r}")
print()
print("Add `category: wire-protocol` (with an RFC anchor in `why:`) or")
print("`category: rest-deferred` (OpenAPI op deferred) to each entry.")
fail = True
if unknown_category:
print(f"::error::api/openapi-handler-exceptions.yaml: {len(unknown_category)} entries with unknown category value (must be wire-protocol or rest-deferred):")
for r, c in unknown_category:
print(f" {r} → category: {c}")
fail = True
# Routes in router but NOT in openapi AND NOT in exceptions = drift
router_only_undocumented = router_set - oapi_set - exception_set
if router_only_undocumented:
print(f"::error::OpenAPI ↔ handler drift: {len(router_only_undocumented)} router routes have no OpenAPI operationId AND are not in api/openapi-handler-exceptions.yaml:")
for m, p in sorted(router_only_undocumented):
print(f" {m:6} {p}")
print()
print("Either:")
print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR")
print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification")
print(" AND a `category: wire-protocol | rest-deferred` field (only protocol-shaped")
print(" or operational routes — health probes, Prometheus scrape, SCEP/EST/ACME")
print(" wire-protocol endpoints, etc. — qualify as wire-protocol).")
fail = True
# Routes in openapi but NOT in router = orphan operationId
oapi_only = oapi_set - router_set
if oapi_only:
print(f"::error::OpenAPI ↔ handler drift: {len(oapi_only)} OpenAPI operations have no router registration:")
for m, p in sorted(oapi_only):
print(f" {m:6} {p}")
print()
print("Either delete the operationId from api/openapi.yaml, OR add the missing")
print("router registration in internal/api/router/router.go.")
fail = True
# Exceptions that don't match any router route = stale exception
stale_exceptions = exception_set - router_set
if stale_exceptions:
print(f"::error::Stale exceptions in api/openapi-handler-exceptions.yaml — these routes are not in the router:")
for m, p in sorted(stale_exceptions):
print(f" {m:6} {p}")
print()
print("Remove the stale entry from api/openapi-handler-exceptions.yaml.")
fail = True
if fail:
sys.exit(1)
print("openapi-handler-parity: clean.")
PY