mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:11:32 +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
461 lines
13 KiB
Go
461 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
// mockAuditService implements AuditService for testing.
|
|
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) {
|
|
if m.listFunc != nil {
|
|
return m.listFunc(page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockAuditService) ListAuditEventsByCategory(_ context.Context, category string, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if m.listByCatFunc != nil {
|
|
return m.listByCatFunc(category, page, perPage)
|
|
}
|
|
if m.listFunc != nil {
|
|
return m.listFunc(page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
|
|
if m.getFunc != nil {
|
|
return m.getFunc(id)
|
|
}
|
|
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{
|
|
{
|
|
ID: "ev-1",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
{
|
|
ID: "ev-2",
|
|
Action: "certificate_renewed",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if page != 1 || perPage != 50 {
|
|
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 1, 50", page, perPage)
|
|
}
|
|
return events, 2, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
// Add request ID to context
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Total != 2 {
|
|
t.Errorf("Total = %d, want 2", result.Total)
|
|
}
|
|
|
|
if result.Page != 1 {
|
|
t.Errorf("Page = %d, want 1", result.Page)
|
|
}
|
|
|
|
if result.PerPage != 50 {
|
|
t.Errorf("PerPage = %d, want 50", result.PerPage)
|
|
}
|
|
|
|
// Check data is present
|
|
if result.Data == nil {
|
|
t.Error("Data is nil, want events slice")
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_WithPagination(t *testing.T) {
|
|
events := []domain.AuditEvent{
|
|
{
|
|
ID: "ev-5",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if page != 2 || perPage != 25 {
|
|
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 2, 25", page, perPage)
|
|
}
|
|
return events, 100, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?page=2&per_page=25", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Page != 2 {
|
|
t.Errorf("Page = %d, want 2", result.Page)
|
|
}
|
|
|
|
if result.PerPage != 25 {
|
|
t.Errorf("PerPage = %d, want 25", result.PerPage)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_PerPageMaxLimit(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
// Should be capped at 500
|
|
if perPage > 500 {
|
|
t.Errorf("perPage = %d, expected <= 500", perPage)
|
|
}
|
|
return []domain.AuditEvent{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?per_page=1000", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.PerPage > 500 {
|
|
t.Errorf("PerPage = %d, want <= 500", result.PerPage)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_EmptyResult(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
return []domain.AuditEvent{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Total != 0 {
|
|
t.Errorf("Total = %d, want 0", result.Total)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_ServiceError(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
return nil, 0, errors.New("database error")
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusInternalServerError {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusInternalServerError)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Failed to list audit events" {
|
|
t.Errorf("Message = %q, want 'Failed to list audit events'", errResp.Message)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_MethodNotAllowed(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_Success(t *testing.T) {
|
|
event := &domain.AuditEvent{
|
|
ID: "ev-123",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
getFunc: func(id string) (*domain.AuditEvent, error) {
|
|
if id != "ev-123" {
|
|
t.Errorf("GetAuditEvent called with id=%q, expected ev-123", id)
|
|
}
|
|
return event, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/ev-123", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result domain.AuditEvent
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.ID != "ev-123" {
|
|
t.Errorf("ID = %q, want ev-123", result.ID)
|
|
}
|
|
|
|
if result.Action != "certificate_issued" {
|
|
t.Errorf("Action = %q, want certificate_issued", result.Action)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_NotFound(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
getFunc: func(id string) (*domain.AuditEvent, error) {
|
|
return nil, errors.New("not found")
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/nonexistent", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusNotFound {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusNotFound)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Audit event not found" {
|
|
t.Errorf("Message = %q, want 'Audit event not found'", errResp.Message)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_MethodNotAllowed(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodDelete, "/api/v1/audit/ev-123", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_EmptyID(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusBadRequest {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusBadRequest)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Audit event ID is required" {
|
|
t.Errorf("Message = %q, want 'Audit event ID is required'", errResp.Message)
|
|
}
|
|
}
|