mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 13:18:52 +00:00
fix(audit): ship streaming NDJSON audit export endpoint (HIGH-9 / HIGH-11)
Audit 2026-05-10 HIGH-9 + HIGH-11 closure. HIGH-10 deferred to v3.
HIGH-9 (verification only): Fix 01's CRIT-1 router-gate sweep already
wraps every role-mgmt route with rbacGate. Verified via grep:
- GET /api/v1/auth/roles → auth.role.list
- POST /api/v1/auth/roles → auth.role.create
- GET /api/v1/auth/roles/{id} → auth.role.list
- PUT /api/v1/auth/roles/{id} → auth.role.edit
- DELETE /api/v1/auth/roles/{id} → auth.role.delete
- POST /api/v1/auth/roles/{id}/permissions → auth.role.edit
- DELETE /api/v1/auth/roles/{id}/permissions/{perm} → auth.role.edit
- POST /api/v1/auth/keys/{id}/roles → auth.role.assign
- DELETE /api/v1/auth/keys/{id}/roles/{role_id} → auth.role.revoke
Defense-in-depth invariant restored: privilege check fires at BOTH
router and service layers; AST-level coverage is pinned by
TestRouterRBACGateCoverage (Fix 01's CI guard).
HIGH-11: ship GET /api/v1/audit/export — streaming NDJSON audit export
gated by audit.export. Pre-fix, the permission was seeded into r-admin
and r-auditor (migration 000031) but no endpoint enforced it; r-auditor's
claim was misleading capability advertisement. Post-fix:
- internal/api/handler/audit.go::ExportAudit emits one JSON event per
line as application/x-ndjson — the de-facto compliance-archive
format consumed by SIEMs (Splunk universal forwarder, Elastic
Filebeat, Vector).
- Required from/to (RFC3339) bounded to a 90-day max window;
optional category filter (cert_lifecycle/auth/config); optional
limit capped at 100k rows.
- Content-Disposition: attachment; filename="certctl-audit-<from>_to_<to>.ndjson"
so curl + browser downloads land with a sensible filename.
- Recursively self-audits: every successful export emits an
audit.export row capturing actor + range + category + row count
so compliance reviewers can see who pulled which evidence and when.
- Service layer: AuditService.ExportEventsByFilter reuses the
existing repository.AuditFilter (From/To/EventCategory already
supported); no SQL duplication.
- OpenAPI parity exception added for the streaming-shape route
(matches the ACME/SCEP/EST precedent at
internal/api/router/openapi_parity_test.go::SpecParityExceptions).
Regression matrix in audit_export_test.go (7 cases):
- TestExportAudit_StreamsNDJSONLines (happy path; pins content-type +
content-disposition + JSON-per-line shape + recursive self-audit)
- TestExportAudit_RejectsRangeBeyond90Days (100-day window → 400)
- TestExportAudit_RejectsMissingFromOrTo (3 cases)
- TestExportAudit_RejectsInvalidCategory (unknown enum → 400)
- TestExportAudit_AcceptsValidCategoryFilter (auth filter passes through)
- TestExportAudit_RejectsNonGET (POST → 405)
- TestExportAudit_RejectsToBeforeFrom (inverted range → 400)
The auditor role's surface is now complete (read + export). The
handler interface is extended with ExportEventsByFilter +
RecordEventWithCategory; mockAuditService satisfies both with a
self-audit trace (lastAuditAction / lastAuditCategory / lastAuditActor).
HIGH-10 (scope + expiry on assignRoleRequest): DEFERRED to v3.
Schema column already exists (ActorRole.ExpiresAt); load-bearing wire
remains v3 work. Documented carve-out at HIGH-10's annotation.
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-9 HIGH-11
Spec: cowork/auth-bundles-fixes-2026-05-10/12-high-9-10-11-role-mgmt-cleanup.md
This commit is contained in:
@@ -144,6 +144,15 @@ var SpecParityExceptions = map[string]string{
|
||||
"POST /api/v1/auth/breakglass/credentials": "Auth Bundle 2 Phase 7.5 — set/rotate password; gated auth.breakglass.admin.",
|
||||
"POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock": "Auth Bundle 2 Phase 7.5 — clear lockout state; gated auth.breakglass.admin.",
|
||||
"DELETE /api/v1/auth/breakglass/credentials/{actor_id}": "Auth Bundle 2 Phase 7.5 — remove credential; gated auth.breakglass.admin.",
|
||||
|
||||
// Audit 2026-05-10 HIGH-11 — streaming NDJSON audit export. Like
|
||||
// other streaming wire-protocol surfaces (ACME, SCEP, EST), the
|
||||
// response is line-oriented application/x-ndjson rather than a
|
||||
// single JSON object; documenting it as a regular OpenAPI operation
|
||||
// would misrepresent the streaming shape. The contract is documented
|
||||
// in docs/operator/security.md::audit-export and the handler doc
|
||||
// comment.
|
||||
"GET /api/v1/audit/export": "Audit 2026-05-10 HIGH-11 — streaming NDJSON audit export; gated audit.export. Documented inline at internal/api/handler/audit.go::ExportAudit.",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -627,6 +627,14 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
|
||||
// Audit routes: /api/v1/audit
|
||||
r.Register("GET /api/v1/audit", rbacGate(reg.Checker, "audit.read", reg.Audit.ListAuditEvents))
|
||||
// Audit 2026-05-10 HIGH-11 closure — `audit.export` permission was
|
||||
// already seeded into r-admin + r-auditor (migration 000031), but
|
||||
// no endpoint enforced it pre-fix; r-auditor's claim was misleading
|
||||
// capability advertisement. The export endpoint makes the grant
|
||||
// load-bearing. Register `/audit/export` BEFORE `/audit/{id}` so
|
||||
// Go's net/http stdlib routing gives the more specific path
|
||||
// precedence over the catch-all.
|
||||
r.Register("GET /api/v1/audit/export", rbacGate(reg.Checker, "audit.export", reg.Audit.ExportAudit))
|
||||
r.Register("GET /api/v1/audit/{id}", rbacGate(reg.Checker, "audit.read", reg.Audit.GetAuditEvent))
|
||||
|
||||
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the
|
||||
|
||||
Reference in New Issue
Block a user