mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
67f346cd87
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).
174 lines
6.8 KiB
Bash
Executable File
174 lines
6.8 KiB
Bash
Executable File
#!/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
|