mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
3287e174dc
Closes the remaining P1 gaps from coverage-gap-audit.md (M-001/M-002/M-003/M-006)
on top of the C-001/C-002 ownership + agent-FK contract fixes landed in
a53a4b8. The work lands as a single commit spanning server, docs, tests,
and the React client.
M-002 — Named API keys with per-key actor propagation
* Migration 000014 adds the 'api_keys' table (id, name, hash,
principal, role, created_at, last_used_at, disabled_at) so every
credential carries an identifiable principal instead of the
opaque 'anonymous'/'api-key' sentinel.
* Auth middleware now rotates through configured keys, performs
constant-time hash comparison, stamps 'last_used_at', and emits
an actor struct via contextWithActor(). The audit middleware,
bulk-revocation handler, approval handlers, and MCP tool layer
now read the principal off the context and persist it on every
audit_events row.
* Regression coverage:
- internal/api/middleware/audit_test.go — actor propagation,
principal redaction for disabled keys, anonymous fallback for
unauthenticated endpoints.
- internal/api/handler/bulk_revocation_handler_test.go,
job_handler_test.go — principal-on-audit assertions.
M-003 — Authorization gates (Phase B)
* Approval handler rejects self-approval / self-rejection with 403
when the actor principal equals the job's requested_by field.
* Bulk revocation is gated behind the 'admin' role; operators and
viewers receive 403.
* Regression coverage:
- internal/service/job_test.go — TestApproveJob_NotSelf,
TestRejectJob_NotSelf.
- internal/api/handler/bulk_revocation_handler_test.go —
TestBulkRevoke_RequiresAdmin, TestBulkRevoke_AdminSucceeds.
M-006 — RFC-compliant CRL/OCSP on the unauthenticated .well-known mux
* Per RFC 8615, relying parties cannot reasonably be asked to
authenticate against the issuing certctl instance to retrieve
revocation material. CRL and OCSP move off the authenticated
'/api/v1/crl*' and '/api/v1/ocsp/*' paths onto:
GET /.well-known/pki/crl/{issuer_id}
Content-Type: application/pkix-crl (RFC 5280 §5)
GET /.well-known/pki/ocsp/{issuer_id}/{serial}
Content-Type: application/ocsp-response (RFC 6960)
* Non-standard JSON CRL shape is removed; only DER is served.
* Short-lived certificate exemption (profile TTL < 1h → skip
CRL/OCSP) is preserved; the response simply omits the serial.
* Routes are registered on the unauthenticated 'finalHandler' mux
in cmd/server/main.go alongside EST ('/.well-known/est/*') and
SCEP ('/scep'). Legacy authenticated paths return 404.
* Regression coverage:
- internal/api/handler/certificate_handler_test.go — content
type, DER parseability, 404 for unknown issuer.
- internal/api/handler/adversarial_path_test.go — unauthenticated
access asserted for CRL, OCSP, EST, SCEP.
- internal/api/router/router_test.go — route-table assertion
that '.well-known/pki/*', '.well-known/est/*', and '/scep' are
mounted on the unauthenticated branch.
M-001 — Auto-closed by M-002
EST and SCEP were already registered on the unauthenticated
'finalHandler' mux; the router comment at
internal/api/router/router.go:247 now matches reality. The
adversarial-path tests above lock the behavior in.
Verification (all gates green):
* go vet ./... — clean
* go build ./... — ok
* go test -short ./... (55+ packages) — all pass
* web/ : npm test (225 Vitest tests) — all pass
* web/ : npx tsc --noEmit — clean
* grep sweep for '/api/v1/(crl|ocsp)' — 13 surviving hits,
all intentional M-006 tombstone/relocation comments.
Documentation:
* coverage-gap-audit.md — status flips M-001/M-002/M-003/M-006 →
Fixed, with per-finding resolution paragraphs citing regression
test IDs. (Audit file lives outside this repo; see cowork root.)
* CLAUDE.md Project Status line updated with the auth-unification
closure note.
* docs/features.md, docs/architecture.md, docs/quickstart.md,
docs/concepts.md, docs/connectors.md, docs/test-env.md,
docs/testing-guide.md, docs/compliance-*.md, docs/demo-advanced.md
— refreshed for the new '.well-known/pki/*' namespace and named
API keys.
* api/openapi.yaml — documents the new unauthenticated endpoints
and removes the legacy '/api/v1/crl*' + '/api/v1/ocsp/*' paths.
.gitignore: adds '/.gocache/' and '/.gomodcache/' for the session-
scoped Go caches so they never enter the tree.
447 lines
13 KiB
Go
447 lines
13 KiB
Go
package router
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/api/handler"
|
|
)
|
|
|
|
// TestNew_ReturnsValidRouter tests that New() returns a properly initialized router.
|
|
func TestNew_ReturnsValidRouter(t *testing.T) {
|
|
r := New()
|
|
if r == nil {
|
|
t.Fatal("expected non-nil router, got nil")
|
|
}
|
|
if r.mux == nil {
|
|
t.Fatal("expected non-nil mux, got nil")
|
|
}
|
|
if r.middleware == nil {
|
|
t.Fatal("expected non-nil middleware slice, got nil")
|
|
}
|
|
if len(r.middleware) != 0 {
|
|
t.Fatalf("expected empty middleware slice, got %d", len(r.middleware))
|
|
}
|
|
}
|
|
|
|
// TestNewWithMiddleware_InitializesMiddleware tests that NewWithMiddleware() applies middlewares.
|
|
func TestNewWithMiddleware_InitializesMiddleware(t *testing.T) {
|
|
called := false
|
|
mw := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
r := NewWithMiddleware(mw)
|
|
if len(r.middleware) != 1 {
|
|
t.Fatalf("expected 1 middleware, got %d", len(r.middleware))
|
|
}
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Register("GET /test", handler)
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if !called {
|
|
t.Error("middleware was not called")
|
|
}
|
|
}
|
|
|
|
// TestRegisterHandlers_RoutesDispatch verifies that RegisterHandlers registers all expected routes.
|
|
// We construct a HandlerRegistry where each handler method writes a unique marker,
|
|
// then verify the expected routes dispatch to the correct handlers.
|
|
func TestRegisterHandlers_RoutesDispatch(t *testing.T) {
|
|
// Create handlers that respond with a marker so we can verify dispatch.
|
|
// The handler structs have zero-value service dependencies which would panic
|
|
// on real calls, so we intercept at the HTTP level using a wrapper.
|
|
r := New()
|
|
|
|
// Track which handler was called
|
|
var lastCalled string
|
|
|
|
// Create a registry with marker-writing handlers using a recovery wrapper.
|
|
// Since zero-value handlers may panic when called (nil service), we wrap the
|
|
// mux in a panic-recovering middleware for this test.
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
// Handler panicked due to nil service — that's expected.
|
|
// The important thing is that the route was matched.
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
reg := HandlerRegistry{
|
|
Certificates: handler.CertificateHandler{},
|
|
Issuers: handler.IssuerHandler{},
|
|
Targets: handler.TargetHandler{},
|
|
Agents: handler.AgentHandler{},
|
|
Jobs: handler.JobHandler{},
|
|
Policies: handler.PolicyHandler{},
|
|
Profiles: handler.ProfileHandler{},
|
|
Teams: handler.TeamHandler{},
|
|
Owners: handler.OwnerHandler{},
|
|
AgentGroups: handler.AgentGroupHandler{},
|
|
Audit: handler.AuditHandler{},
|
|
Notifications: handler.NotificationHandler{},
|
|
Stats: handler.StatsHandler{},
|
|
Metrics: handler.MetricsHandler{},
|
|
Health: handler.NewHealthHandler("api-key"),
|
|
Discovery: handler.DiscoveryHandler{},
|
|
NetworkScan: handler.NetworkScanHandler{},
|
|
Verification: handler.VerificationHandler{},
|
|
Export: handler.ExportHandler{},
|
|
Digest: handler.DigestHandler{},
|
|
}
|
|
|
|
r.RegisterHandlers(reg)
|
|
|
|
// Wrap the router with recovery middleware for testing
|
|
testHandler := recoverMW(r)
|
|
|
|
// Test a representative sample of routes. We just check that the route
|
|
// is registered (doesn't return 404). The handler may panic (caught by recoverMW)
|
|
// or return an error, but NOT 404.
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
// Health (registered outside middleware chain)
|
|
{"GET", "/health"},
|
|
{"GET", "/ready"},
|
|
{"GET", "/api/v1/auth/info"},
|
|
{"GET", "/api/v1/auth/check"},
|
|
|
|
// Certificates CRUD
|
|
{"GET", "/api/v1/certificates"},
|
|
{"POST", "/api/v1/certificates"},
|
|
{"GET", "/api/v1/certificates/mc-test"},
|
|
{"PUT", "/api/v1/certificates/mc-test"},
|
|
{"DELETE", "/api/v1/certificates/mc-test"},
|
|
{"GET", "/api/v1/certificates/mc-test/versions"},
|
|
{"GET", "/api/v1/certificates/mc-test/deployments"},
|
|
{"POST", "/api/v1/certificates/mc-test/renew"},
|
|
{"POST", "/api/v1/certificates/mc-test/deploy"},
|
|
{"POST", "/api/v1/certificates/mc-test/revoke"},
|
|
|
|
// Export
|
|
{"GET", "/api/v1/certificates/mc-test/export/pem"},
|
|
|
|
// NOTE: CRL/OCSP moved out of /api/v1/* in M-006. They are now served
|
|
// unauthenticated at /.well-known/pki/* via RegisterPKIHandlers and
|
|
// are verified in TestRegisterPKIHandlers_AllPaths below.
|
|
|
|
// Issuers
|
|
{"GET", "/api/v1/issuers"},
|
|
{"POST", "/api/v1/issuers"},
|
|
{"GET", "/api/v1/issuers/iss-test"},
|
|
{"PUT", "/api/v1/issuers/iss-test"},
|
|
{"DELETE", "/api/v1/issuers/iss-test"},
|
|
{"POST", "/api/v1/issuers/iss-test/test"},
|
|
|
|
// Targets
|
|
{"GET", "/api/v1/targets"},
|
|
{"POST", "/api/v1/targets"},
|
|
{"GET", "/api/v1/targets/t-test"},
|
|
{"PUT", "/api/v1/targets/t-test"},
|
|
{"DELETE", "/api/v1/targets/t-test"},
|
|
{"POST", "/api/v1/targets/t-test/test"},
|
|
|
|
// Agents
|
|
{"GET", "/api/v1/agents"},
|
|
{"POST", "/api/v1/agents"},
|
|
{"GET", "/api/v1/agents/agent-1"},
|
|
{"POST", "/api/v1/agents/agent-1/heartbeat"},
|
|
{"POST", "/api/v1/agents/agent-1/csr"},
|
|
{"GET", "/api/v1/agents/agent-1/certificates/mc-1"},
|
|
{"GET", "/api/v1/agents/agent-1/work"},
|
|
{"POST", "/api/v1/agents/agent-1/jobs/job-1/status"},
|
|
|
|
// Jobs
|
|
{"GET", "/api/v1/jobs"},
|
|
{"GET", "/api/v1/jobs/job-1"},
|
|
{"POST", "/api/v1/jobs/job-1/cancel"},
|
|
{"POST", "/api/v1/jobs/job-1/approve"},
|
|
{"POST", "/api/v1/jobs/job-1/reject"},
|
|
|
|
// Policies
|
|
{"GET", "/api/v1/policies"},
|
|
{"POST", "/api/v1/policies"},
|
|
{"GET", "/api/v1/policies/pol-1"},
|
|
{"PUT", "/api/v1/policies/pol-1"},
|
|
{"DELETE", "/api/v1/policies/pol-1"},
|
|
{"GET", "/api/v1/policies/pol-1/violations"},
|
|
|
|
// Profiles
|
|
{"GET", "/api/v1/profiles"},
|
|
{"POST", "/api/v1/profiles"},
|
|
{"GET", "/api/v1/profiles/prof-1"},
|
|
{"PUT", "/api/v1/profiles/prof-1"},
|
|
{"DELETE", "/api/v1/profiles/prof-1"},
|
|
|
|
// Teams
|
|
{"GET", "/api/v1/teams"},
|
|
{"POST", "/api/v1/teams"},
|
|
{"GET", "/api/v1/teams/team-1"},
|
|
|
|
// Owners
|
|
{"GET", "/api/v1/owners"},
|
|
{"POST", "/api/v1/owners"},
|
|
{"GET", "/api/v1/owners/owner-1"},
|
|
|
|
// Agent Groups
|
|
{"GET", "/api/v1/agent-groups"},
|
|
{"POST", "/api/v1/agent-groups"},
|
|
{"GET", "/api/v1/agent-groups/ag-1"},
|
|
{"GET", "/api/v1/agent-groups/ag-1/members"},
|
|
|
|
// Audit
|
|
{"GET", "/api/v1/audit"},
|
|
{"GET", "/api/v1/audit/evt-1"},
|
|
|
|
// Notifications
|
|
{"GET", "/api/v1/notifications"},
|
|
{"GET", "/api/v1/notifications/notif-1"},
|
|
{"POST", "/api/v1/notifications/notif-1/read"},
|
|
|
|
// Stats
|
|
{"GET", "/api/v1/stats/summary"},
|
|
{"GET", "/api/v1/stats/certificates-by-status"},
|
|
{"GET", "/api/v1/stats/expiration-timeline"},
|
|
{"GET", "/api/v1/stats/job-trends"},
|
|
{"GET", "/api/v1/stats/issuance-rate"},
|
|
|
|
// Metrics
|
|
{"GET", "/api/v1/metrics"},
|
|
{"GET", "/api/v1/metrics/prometheus"},
|
|
|
|
// Discovery
|
|
{"POST", "/api/v1/agents/agent-1/discoveries"},
|
|
{"GET", "/api/v1/discovered-certificates"},
|
|
{"GET", "/api/v1/discovered-certificates/dc-1"},
|
|
{"POST", "/api/v1/discovered-certificates/dc-1/claim"},
|
|
{"POST", "/api/v1/discovered-certificates/dc-1/dismiss"},
|
|
{"GET", "/api/v1/discovery-scans"},
|
|
{"GET", "/api/v1/discovery-summary"},
|
|
|
|
// Network scan
|
|
{"GET", "/api/v1/network-scan-targets"},
|
|
{"POST", "/api/v1/network-scan-targets"},
|
|
{"GET", "/api/v1/network-scan-targets/nst-1"},
|
|
{"PUT", "/api/v1/network-scan-targets/nst-1"},
|
|
{"DELETE", "/api/v1/network-scan-targets/nst-1"},
|
|
{"POST", "/api/v1/network-scan-targets/nst-1/scan"},
|
|
|
|
// Verification
|
|
{"POST", "/api/v1/jobs/job-1/verify"},
|
|
{"GET", "/api/v1/jobs/job-1/verification"},
|
|
|
|
// Digest
|
|
{"GET", "/api/v1/digest/preview"},
|
|
{"POST", "/api/v1/digest/send"},
|
|
}
|
|
|
|
_ = lastCalled // suppress unused
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
// Route should NOT return 404 (route not found) or 405 (method not allowed)
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("route %s %s returned 405 — method not allowed", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisterHandlers_UnregisteredRoute verifies 404 for non-existent route.
|
|
func TestRegisterHandlers_UnregisteredRoute(t *testing.T) {
|
|
r := New()
|
|
reg := HandlerRegistry{
|
|
Health: handler.NewHealthHandler("api-key"),
|
|
}
|
|
r.RegisterHandlers(reg)
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/nonexistent", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for nonexistent route, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestRegisterESTHandlers_AllPaths verifies EST route registration.
|
|
func TestRegisterESTHandlers_AllPaths(t *testing.T) {
|
|
r := New()
|
|
|
|
// EST handler with zero-value services will panic, so wrap with recovery
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
est := handler.ESTHandler{}
|
|
r.RegisterESTHandlers(est)
|
|
|
|
testHandler := recoverMW(r)
|
|
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{"GET", "/.well-known/est/cacerts"},
|
|
{"POST", "/.well-known/est/simpleenroll"},
|
|
{"POST", "/.well-known/est/simplereenroll"},
|
|
{"GET", "/.well-known/est/csrattrs"},
|
|
}
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("EST route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("EST route %s %s returned 405", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisterPKIHandlers_AllPaths verifies that RegisterPKIHandlers registers
|
|
// the two RFC-compliant unauthenticated endpoints relocated in M-006:
|
|
//
|
|
// - GET /.well-known/pki/crl/{issuer_id} (RFC 5280 §5 DER CRL)
|
|
// - GET /.well-known/pki/ocsp/{issuer_id}/{serial} (RFC 6960 §2.1 OCSP)
|
|
//
|
|
// Registration and middleware gating are complementary: this test proves the
|
|
// router matches the path; the unauthenticated contract is enforced separately
|
|
// by cmd/server/main.go's finalHandler routing /.well-known/pki/* through the
|
|
// noAuthHandler.
|
|
func TestRegisterPKIHandlers_AllPaths(t *testing.T) {
|
|
r := New()
|
|
|
|
// Zero-value CertificateHandler will panic on real calls; the only thing
|
|
// this test is verifying is that the route dispatches (i.e. the URL
|
|
// pattern is registered), so catch the downstream panic.
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
r.RegisterPKIHandlers(handler.CertificateHandler{})
|
|
testHandler := recoverMW(r)
|
|
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{"GET", "/.well-known/pki/crl/iss-local"},
|
|
{"GET", "/.well-known/pki/ocsp/iss-local/01ABCDEF"},
|
|
}
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("PKI route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("PKI route %s %s returned 405", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetMux_ReturnsUnderlyingMux tests that GetMux returns the underlying mux.
|
|
func TestGetMux_ReturnsUnderlyingMux(t *testing.T) {
|
|
r := New()
|
|
mux := r.GetMux()
|
|
if mux == nil {
|
|
t.Fatal("expected non-nil mux from GetMux, got nil")
|
|
}
|
|
if mux != r.mux {
|
|
t.Error("GetMux should return the underlying mux")
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareOrder tests that middlewares are applied in the correct order.
|
|
func TestMiddlewareOrder(t *testing.T) {
|
|
var order []string
|
|
|
|
mw1 := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "mw1-before")
|
|
next.ServeHTTP(w, r)
|
|
order = append(order, "mw1-after")
|
|
})
|
|
}
|
|
|
|
mw2 := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "mw2-before")
|
|
next.ServeHTTP(w, r)
|
|
order = append(order, "mw2-after")
|
|
})
|
|
}
|
|
|
|
r := NewWithMiddleware(mw1, mw2)
|
|
|
|
r.RegisterFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "handler")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
expected := []string{"mw1-before", "mw2-before", "handler", "mw2-after", "mw1-after"}
|
|
|
|
if len(order) != len(expected) {
|
|
t.Fatalf("middleware order length mismatch: expected %d, got %d", len(expected), len(order))
|
|
}
|
|
|
|
for i, v := range order {
|
|
if v != expected[i] {
|
|
t.Errorf("middleware order[%d]: expected %q, got %q", i, expected[i], v)
|
|
}
|
|
}
|
|
}
|