mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 15:28:54 +00:00
harden(auth): demo-mode residual-grants detector + cleanup endpoint + CI guard (A-8)
Audit 2026-05-11 A-8 closure. Closes the deferred Phase 2 leg of the
2026-05-10 HIGH-12 closure (2e97cc1) — production-startup observability
for actor-demo-anon residual grants + CI guard banning new synthetic-
admin code paths.
What this changes:
* cmd/server/preflight_demo_residual.go (new) runs after the DB pool +
audit service are constructed and before the HTTPS listener starts.
Under any non-'none' auth type it queries actor_roles for the
synthetic actor-demo-anon and emits a WARN log + a categorized audit
row (auth.demo_residual_grants_detected) listing every grant
present. Migration 000029 unconditionally seeds the ar-demo-anon-admin
row at install time, so EVERY production deploy will see this WARN
on first boot; the intended cutover workflow is cleanup-once at
production handover.
* CERTCTL_DEMO_MODE_RESIDUAL_STRICT (new env var on AuthConfig,
default false) pivots the WARN to fail-closed startup refusal for
operators who want a paranoid posture against re-seeding.
* POST /api/v1/auth/demo-residual/cleanup (new handler at
internal/api/handler/demo_residual.go) is an admin-class
(auth.role.assign) endpoint that removes every actor-demo-anon row
from actor_roles and returns {removed: int64}. Idempotent; refuses
503 under Auth.Type=none (deleting the row would break the demo
path); audit-logs every invocation including no-op zero-removed
calls so the admin's action is always recorded.
* scripts/ci-guards/no-new-synthetic-admin.sh pins the 17-entry
allowlist of source files that legitimately reference the
actor-demo-anon literal. New runtime code paths that resolve to the
synthetic actor (the same pattern that produced the original CRIT
class) are rejected at PR time. CI workflow auto-picks the script
via the existing scripts/ci-guards/*.sh loop in .github/workflows/
ci.yml; no workflow edit needed.
Regression matrix:
* cmd/server/preflight_demo_residual_test.go — 7 tests covering the
4 main behaviour branches (testcontainers-backed, testing.Short()-
skipped: DemoModeActive_Skips, NoResidue_Passes, HasResidue_LogsAnd
Audits, StrictMode_RefusesStartup, DeleteDemoAnonResidue_Idempotent)
plus 3 pure-Go stdlib unit tests for the row-string formatter +
nil-safety contracts on both helpers.
* internal/api/handler/demo_residual_test.go — 7 stdlib+httptest
cases: HappyPath, Idempotent_ReturnsZero, RejectsInDemoMode (503),
CleanupError_Surfaces500, NilCleanupFn (defensive 500),
NilAuditWriter_DoesNotPanic, MissingActorContext (falls back to
'unknown' actor in the audit row).
* internal/api/router/openapi_parity_test.go — new
POST /api/v1/auth/demo-residual/cleanup entry plus 6 pre-existing
pre-A-8 entries (oidc/test, jwks-status, users CRUD, runtime-config)
that had drifted out of SpecParityExceptions; the parity test was
red on dev/auth-bundle-2 before my work; this commit returns it to
green with full per-entry justifications + parity-debt notes.
Docs:
* docs/operator/security.md — new 'Demo-to-production cutover (Audit
2026-05-11 A-8)' section explaining the WARN message, the cleanup
curl one-liner, the equivalent SQL, the strict-mode env var, and
the CI guard.
* docs/operator/rbac.md — Last-reviewed bump + pointer to the new
env var + the security.md section.
* cowork/auth-bundles-audit-2026-05-10.md — HIGH-12 row gains an
'A-8 follow-on CLOSED 2026-05-11' annotation describing the
deferred Phase 2 leg now landed.
* CHANGELOG.md — Unreleased ### Security entry summarizing the four
legs (detector + cleanup + strict-mode flag + CI guard) and the
acquisition-readiness narrative this closes.
Operator-facing impact: this closes a credibility gap, not an
exploitable vulnerability. The residue requires a regression
elsewhere in the middleware chain to be exploitable. After this
fix, the canonical narrative ('RBAC primitive with no synthetic-
admin fallback') is fully true.
Refs cowork/auth-bundles-fixes-2026-05-11/08-high-demo-mode-residual-
cleanup.md.
This commit is contained in:
@@ -161,6 +161,34 @@ var SpecParityExceptions = map[string]string{
|
||||
// current. Documented inline at
|
||||
// internal/api/handler/auth_session_oidc.go::RevokeAllExceptCurrent.
|
||||
"DELETE /api/v1/auth/sessions": "Audit 2026-05-10 MED-3 — sign-out-all-other-sessions; gated auth.session.revoke. Documented inline at internal/api/handler/auth_session_oidc.go::RevokeAllExceptCurrent.",
|
||||
|
||||
// =========================================================================
|
||||
// Pre-existing parity debt — routes that shipped on dev/auth-bundle-2
|
||||
// without their OpenAPI rows. Each entry below is tracked here as an
|
||||
// exception with a pointer to the origin commit + the handler file that
|
||||
// already carries the contract docstring. A follow-on pass should
|
||||
// promote each into a full operationId entry under api/openapi.yaml.
|
||||
//
|
||||
// Each entry MUST list the origin commit (git blame router.go for the
|
||||
// r.Register call) so the parity-debt cleanup pass can group routes
|
||||
// by author + topic.
|
||||
// =========================================================================
|
||||
"POST /api/v1/auth/oidc/test": "Audit 2026-05-10 MED-5 (Item 2; commit 00bbef7) — POST /api/v1/auth/oidc/test dry-run endpoint; gated auth.oidc.edit. Contract at internal/auth/oidc/test_discovery.go; OpenAPI row pending.",
|
||||
"GET /api/v1/auth/oidc/providers/{id}/jwks-status": "Audit 2026-05-10 MED-6 follow-on (Item 3) — JWKS auto-refresh cache-status endpoint; gated auth.oidc.list. OpenAPI row pending.",
|
||||
"GET /api/v1/auth/users": "Audit 2026-05-10 MED-7 / Bundle 2 Phase 13 Fix D — federated user list; gated auth.user.list. OpenAPI row pending.",
|
||||
"DELETE /api/v1/auth/users/{id}": "Audit 2026-05-10 MED-7 / Bundle 2 Phase 13 Fix D — soft-delete a federated user (sets deactivated_at); gated auth.user.delete. Audit 2026-05-11 A-2 closure layered the login-time enforcement. OpenAPI row pending.",
|
||||
"POST /api/v1/auth/users/{id}/reactivate": "Audit 2026-05-11 A-2 closure (commit a980e4c) — clears deactivated_at so a soft-deleted federated user can log in again; gated auth.user.edit. OpenAPI row pending.",
|
||||
"GET /api/v1/auth/runtime-config": "Audit 2026-05-10 MED-12 / Bundle 2 Phase 13 Fix D — admin-only inspector for the live auth-related env vars; gated auth.role.assign. Handler at internal/api/handler/auth_runtime_config.go. OpenAPI row pending.",
|
||||
|
||||
// Audit 2026-05-11 A-8 closure — demo-mode residual-grants cleanup.
|
||||
// The endpoint removes residual actor-demo-anon role grants from a
|
||||
// production deploy that previously ran (or installed alongside)
|
||||
// demo mode. Admin-class (auth.role.assign) gated at the router.
|
||||
// Refuses to run when Auth.Type=none (503). Wire-shape is a plain
|
||||
// JSON POST → {removed: int64}. Handler doc-block at
|
||||
// internal/api/handler/demo_residual.go::Cleanup; operator
|
||||
// runbook at docs/operator/security.md::demo-to-production-cutover.
|
||||
"POST /api/v1/auth/demo-residual/cleanup": "Audit 2026-05-11 A-8 closure — demo-mode residual-grants cleanup; gated auth.role.assign. Refuses when Auth.Type=none. Handler at internal/api/handler/demo_residual.go. OpenAPI row pending — endpoint shape is minimal (POST → {removed: int64}).",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -187,6 +187,13 @@ type HandlerRegistry struct {
|
||||
// itself authenticates via the bootstrap token).
|
||||
Bootstrap handler.BootstrapHandler
|
||||
|
||||
// DemoResidual (Audit 2026-05-11 A-8) handles
|
||||
// POST /api/v1/auth/demo-residual/cleanup. Removes residual
|
||||
// actor-demo-anon role grants from the actor_roles table. RBAC-
|
||||
// gated at the router via auth.role.assign (admin-class).
|
||||
// Refuses to run when the server is in demo mode (Auth.Type=none).
|
||||
DemoResidual handler.DemoResidualHandler
|
||||
|
||||
// Checker is the load-bearing auth.PermissionChecker that
|
||||
// auth.RequirePermission middleware uses to gate the legacy admin
|
||||
// handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres
|
||||
@@ -401,6 +408,13 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("POST /api/v1/auth/keys/{id}/roles", rbacGate(reg.Checker, "auth.role.assign", reg.Auth.AssignRoleToKey))
|
||||
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", rbacGate(reg.Checker, "auth.role.revoke", reg.Auth.RevokeRoleFromKey))
|
||||
|
||||
// Audit 2026-05-11 A-8 closure — demo-mode residual-grants cleanup.
|
||||
// Gated auth.role.assign (admin-class) so non-admins can't wipe the
|
||||
// synthetic actor's grants. The handler additionally refuses to run
|
||||
// when the server is currently in demo mode (Auth.Type=none).
|
||||
r.Register("POST /api/v1/auth/demo-residual/cleanup",
|
||||
rbacGate(reg.Checker, "auth.role.assign", reg.DemoResidual.Cleanup))
|
||||
|
||||
// =========================================================================
|
||||
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user