Files
certctl/internal/api/handler/audit_export_test.go
T
shankar0123 912ec3f547 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
2026-05-10 21:36:01 +00:00

190 lines
6.0 KiB
Go

package handler
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/certctl-io/certctl/internal/domain"
)
// Audit 2026-05-10 HIGH-11 closure — pin the streaming NDJSON audit
// export endpoint. Pre-fix, the `audit.export` permission was seeded
// into r-admin + r-auditor (migration 000031) but no endpoint enforced
// it; the auditor role's claim was misleading capability advertisement.
// Post-fix, GET /api/v1/audit/export gates on `audit.export`, streams
// audit rows as line-delimited JSON, bounded to a 90-day window, and
// recursively self-audits each export call.
// exportMockSvc extends mockAuditService with explicit hooks for the
// HIGH-11 export path.
type exportMockSvc struct {
mockAuditService
exportFn func(from, to time.Time, eventCategory string, maxRows int) ([]domain.AuditEvent, error)
}
func (m *exportMockSvc) ExportEventsByFilter(_ context.Context, from, to time.Time, eventCategory string, maxRows int) ([]domain.AuditEvent, error) {
if m.exportFn != nil {
return m.exportFn(from, to, eventCategory, maxRows)
}
return nil, nil
}
func TestExportAudit_StreamsNDJSONLines(t *testing.T) {
events := []domain.AuditEvent{
{ID: "ev-1", Action: "cert.issue", Actor: "alice", Timestamp: time.Now()},
{ID: "ev-2", Action: "cert.revoke", Actor: "bob", Timestamp: time.Now()},
{ID: "ev-3", Action: "auth.role.grant", Actor: "alice", Timestamp: time.Now()},
}
mockSvc := &exportMockSvc{
exportFn: func(from, to time.Time, _ string, _ int) ([]domain.AuditEvent, error) {
return events, nil
},
}
h := NewAuditHandler(mockSvc)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/audit/export?from=2026-04-01T00:00:00Z&to=2026-05-01T00:00:00Z", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; want 200; body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/x-ndjson" {
t.Errorf("Content-Type = %q; want application/x-ndjson", ct)
}
if cd := w.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") {
t.Errorf("Content-Disposition = %q; want attachment;...", cd)
}
scanner := bufio.NewScanner(strings.NewReader(w.Body.String()))
count := 0
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var got domain.AuditEvent
if err := json.Unmarshal([]byte(line), &got); err != nil {
t.Errorf("line %d not valid JSON: %v; line=%s", count, err, line)
}
count++
}
if count != len(events) {
t.Errorf("scanned %d NDJSON lines; want %d", count, len(events))
}
// Self-audit leg: the export must emit an audit.export row for the
// recursive trail.
if mockSvc.lastAuditAction != "audit.export" {
t.Errorf("lastAuditAction = %q; want audit.export (recursive self-audit)", mockSvc.lastAuditAction)
}
if mockSvc.lastAuditCategory != domain.EventCategoryAuth {
t.Errorf("lastAuditCategory = %q; want %q", mockSvc.lastAuditCategory, domain.EventCategoryAuth)
}
}
func TestExportAudit_RejectsRangeBeyond90Days(t *testing.T) {
mockSvc := &exportMockSvc{}
h := NewAuditHandler(mockSvc)
// 100-day window — must reject.
req := httptest.NewRequest(http.MethodGet,
"/api/v1/audit/export?from=2026-01-01T00:00:00Z&to=2026-04-15T00:00:00Z", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400 for >90d range", w.Code)
}
if !strings.Contains(w.Body.String(), "90-day") {
t.Errorf("body = %q; want it to mention the 90-day cap", w.Body.String())
}
}
func TestExportAudit_RejectsMissingFromOrTo(t *testing.T) {
mockSvc := &exportMockSvc{}
h := NewAuditHandler(mockSvc)
cases := []string{
"/api/v1/audit/export",
"/api/v1/audit/export?from=2026-04-01T00:00:00Z",
"/api/v1/audit/export?to=2026-04-30T00:00:00Z",
}
for _, url := range cases {
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("URL %q: status = %d; want 400 (missing from/to)", url, w.Code)
}
}
}
func TestExportAudit_RejectsInvalidCategory(t *testing.T) {
mockSvc := &exportMockSvc{}
h := NewAuditHandler(mockSvc)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/audit/export?from=2026-04-01T00:00:00Z&to=2026-04-30T00:00:00Z&category=zzz_unknown", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400 for invalid category", w.Code)
}
}
func TestExportAudit_AcceptsValidCategoryFilter(t *testing.T) {
captured := struct {
category string
}{}
mockSvc := &exportMockSvc{
exportFn: func(_, _ time.Time, eventCategory string, _ int) ([]domain.AuditEvent, error) {
captured.category = eventCategory
return []domain.AuditEvent{}, nil
},
}
h := NewAuditHandler(mockSvc)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/audit/export?from=2026-04-01T00:00:00Z&to=2026-04-30T00:00:00Z&category=auth", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; want 200; body=%s", w.Code, w.Body.String())
}
if captured.category != domain.EventCategoryAuth {
t.Errorf("captured.category = %q; want %q", captured.category, domain.EventCategoryAuth)
}
}
func TestExportAudit_RejectsNonGET(t *testing.T) {
mockSvc := &exportMockSvc{}
h := NewAuditHandler(mockSvc)
req := httptest.NewRequest(http.MethodPost,
"/api/v1/audit/export?from=2026-04-01T00:00:00Z&to=2026-04-30T00:00:00Z", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d; want 405 for POST", w.Code)
}
}
func TestExportAudit_RejectsToBeforeFrom(t *testing.T) {
mockSvc := &exportMockSvc{}
h := NewAuditHandler(mockSvc)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/audit/export?from=2026-05-01T00:00:00Z&to=2026-04-01T00:00:00Z", nil)
w := httptest.NewRecorder()
h.ExportAudit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400 (to before from)", w.Code)
}
}