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:
shankar0123
2026-05-10 21:36:01 +00:00
parent 2e97cc10b8
commit 912ec3f547
6 changed files with 436 additions and 0 deletions
@@ -18,6 +18,10 @@ type mockAuditService struct {
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
listByCatFunc func(category string, page, perPage int) ([]domain.AuditEvent, int64, error)
getFunc func(id string) (*domain.AuditEvent, error)
// HIGH-11 self-audit trace — last RecordEventWithCategory call.
lastAuditActor string
lastAuditAction string
lastAuditCategory string
}
func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
@@ -44,6 +48,32 @@ func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.
return nil, nil
}
// ExportEventsByFilter satisfies the Audit 2026-05-10 HIGH-11 interface
// extension. The test mock just defers to the existing list helpers
// (no separate export-specific test fixture needed for the bundles that
// don't exercise export).
func (m *mockAuditService) ExportEventsByFilter(_ context.Context, _, _ time.Time, eventCategory string, _ int) ([]domain.AuditEvent, error) {
if m.listFunc != nil {
events, _, err := m.listFunc(1, 50000)
if err != nil {
return nil, err
}
return events, nil
}
return nil, nil
}
// RecordEventWithCategory satisfies the Audit 2026-05-10 HIGH-11
// interface extension (the export handler self-audits each call).
// Tests that don't care about the audit row trace can leave the field
// nil; tests that do can read m.lastAuditAction etc. after the call.
func (m *mockAuditService) RecordEventWithCategory(_ context.Context, actor string, _ domain.ActorType, action, eventCategory, _, _ string, _ map[string]interface{}) error {
m.lastAuditActor = actor
m.lastAuditAction = action
m.lastAuditCategory = eventCategory
return nil
}
func TestListAuditEvents_Success(t *testing.T) {
events := []domain.AuditEvent{
{