mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:31:33 +00:00
912ec3f547
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
190 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|