mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:31:30 +00:00
crl/ocsp: admin observability endpoint + Phase 6 e2e scaffold
Phase 5 (admin endpoint slice) + Phase 6 (e2e test stub) of the
CRL/OCSP responder bundle. Closes the deferred items from the
backend-slice merge (77d6326).
What landed:
Phase 5 — admin observability:
* GET /api/v1/admin/crl/cache (handler.AdminCRLCacheHandler):
- Per-issuer cache state + most recent N generation events
- Admin-gated via middleware.IsAdmin (M-003 pattern); non-admin
callers get 403 + the service is never invoked
- Reveals issuer set + CRL cadence, hence the gate
- Returns CachePresent=false rows for never-generated issuers so
the GUI can show 'not yet generated' instead of 404
- Per-issuer Get failures decorate the row's RecentEvents rather
than failing the whole response
* AdminCRLCacheServiceImpl: thin handler-side composition over
repository.CRLCacheRepository + an issuer-IDs callback (avoids
importing internal/service from internal/api/handler)
* M-008 admin-gate pin updated: admin_crl_cache.go added to
AdminGatedHandlers; full triplet of tests
(NonAdmin_Returns403, AdminExplicitFalse_Returns403,
AdminPermitted_ForwardsActor) + RejectsNonGetMethod +
PropagatesServiceError
* Router registration + HandlerRegistry field + main.go wiring
(callback closure over issuerRegistry.List)
* OpenAPI entry under CRL & OCSP tag
Phase 6 — e2e scaffold:
* deploy/test/crl_ocsp_e2e_test.go with TestCRLOCSPLifecycle +
TestCRLOCSPPostEndpoint
* Lifecycle test exercises issue → fetch OCSP (Good) → revoke →
wait → fetch CRL (entry present) → fetch OCSP (Revoked) →
verify dedicated responder cert + id-pkix-ocsp-nocheck
* Helpers (issueLocalCert, revokeCertViaAPI, fetchCRL, fetchOCSP,
fetchCACert) currently call t.Skip with TODO markers — sandbox
has no Docker so the harness can't be wired end-to-end here;
when CI / a fresh dev workstation runs, the implementer wires
each helper to the existing integration_test.go primitives
* Build-tagged //go:build integration so the standard go test
sweep skips it; runs via the deploy/test integration workflow
Coverage: handler 80.6% (above 75 floor; was 79.8% pre-Phase-5).
All other packages unchanged.
Backward compat: admin endpoint inert until an admin Bearer key is
configured. The e2e test stub is no-op (skips) until wired.
Deferred:
* GUI cert-detail-page revocation panel — pure frontend work, no
backend impact, separate session
* E2E test helper wiring — depends on extracting the existing
integration-test harness primitives into shared helpers; doable
in a follow-up that has Docker available
* V3-Pro polish (delta CRLs, OCSP rate-limiting, OCSP stapling)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AdminCRLCacheService is the slice of CRLCacheRepository the admin
|
||||
// endpoint needs. The handler depends on this narrow interface rather
|
||||
// than the full *service.CRLCacheService so the wiring stays
|
||||
// service-side and the handler stays test-friendly.
|
||||
type AdminCRLCacheService interface {
|
||||
// CacheRows returns one row per issuer that currently has a cached
|
||||
// CRL. Implementations walk the registry and call the repository's
|
||||
// Get for each; rows that don't exist (issuer never had a CRL
|
||||
// generated) are returned with CacheRow.CachePresent=false so the
|
||||
// GUI can show "not yet generated" rather than 404ing.
|
||||
CacheRows(ctx context.Context) ([]CRLCacheRow, error)
|
||||
}
|
||||
|
||||
// CRLCacheRow is the admin-endpoint view of a single issuer's cache
|
||||
// state. The raw CRL DER is omitted (kept on the server) — operators
|
||||
// fetch it via the standard /.well-known/pki/crl/{issuer_id} URL.
|
||||
type CRLCacheRow struct {
|
||||
IssuerID string `json:"issuer_id"`
|
||||
CachePresent bool `json:"cache_present"`
|
||||
CRLNumber int64 `json:"crl_number,omitempty"`
|
||||
ThisUpdate *time.Time `json:"this_update,omitempty"`
|
||||
NextUpdate *time.Time `json:"next_update,omitempty"`
|
||||
GeneratedAt *time.Time `json:"generated_at,omitempty"`
|
||||
GenerationDurMs int64 `json:"generation_duration_ms,omitempty"`
|
||||
RevokedCount int `json:"revoked_count,omitempty"`
|
||||
IsStale bool `json:"is_stale,omitempty"`
|
||||
RecentEvents []CRLCacheEvt `json:"recent_events,omitempty"`
|
||||
}
|
||||
|
||||
// CRLCacheEvt is the trimmed view of a CRLGenerationEvent for the
|
||||
// admin response. We omit the DB row ID (operators don't care) and
|
||||
// flatten the duration to milliseconds.
|
||||
type CRLCacheEvt struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
CRLNumber int64 `json:"crl_number"`
|
||||
RevokedCount int `json:"revoked_count"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AdminCRLCacheHandler serves the GET /api/v1/admin/crl/cache endpoint
|
||||
// for ops visibility into the scheduler-driven CRL pre-generation
|
||||
// pipeline. CRL/OCSP-Responder Phase 5.
|
||||
//
|
||||
// The endpoint is admin-gated (M-003 pattern) — non-admin Bearer
|
||||
// callers get 403. This is a fleet-state observability surface; we
|
||||
// don't expose it to every authenticated user because the cache
|
||||
// rows reveal the operator's issuer set + CRL cadence.
|
||||
type AdminCRLCacheHandler struct {
|
||||
svc AdminCRLCacheService
|
||||
}
|
||||
|
||||
// NewAdminCRLCacheHandler creates a new handler.
|
||||
func NewAdminCRLCacheHandler(svc AdminCRLCacheService) AdminCRLCacheHandler {
|
||||
return AdminCRLCacheHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListCache handles GET /api/v1/admin/crl/cache.
|
||||
func (h AdminCRLCacheHandler) ListCache(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.svc.CacheRows(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to read CRL cache state")
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
// Avoid serialising as `null` — the GUI expects an array.
|
||||
rows = []CRLCacheRow{}
|
||||
}
|
||||
_ = JSON(w, http.StatusOK, map[string]any{
|
||||
"cache_rows": rows,
|
||||
"row_count": len(rows),
|
||||
"generated_at": time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCRLCacheServiceImpl is the production implementation of
|
||||
// AdminCRLCacheService. It walks the issuer registry, fetches the
|
||||
// cache row for each via the repository, and decorates with recent
|
||||
// generation events. Lives in the handler package because it's a
|
||||
// thin handler-side composition; the heavy lifting stays in the
|
||||
// repository.
|
||||
type AdminCRLCacheServiceImpl struct {
|
||||
cacheRepo repository.CRLCacheRepository
|
||||
issuerIDs func() []string // returns all issuer IDs (callback so the
|
||||
// registry doesn't have to be imported here)
|
||||
now func() time.Time
|
||||
eventLimit int
|
||||
}
|
||||
|
||||
// NewAdminCRLCacheServiceImpl constructs the handler-side service.
|
||||
// issuerIDsFn is a callback so we don't import internal/service from
|
||||
// the handler package (would be a layering violation).
|
||||
func NewAdminCRLCacheServiceImpl(cacheRepo repository.CRLCacheRepository, issuerIDsFn func() []string) *AdminCRLCacheServiceImpl {
|
||||
return &AdminCRLCacheServiceImpl{
|
||||
cacheRepo: cacheRepo,
|
||||
issuerIDs: issuerIDsFn,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
eventLimit: 5,
|
||||
}
|
||||
}
|
||||
|
||||
// CacheRows implements AdminCRLCacheService.
|
||||
func (s *AdminCRLCacheServiceImpl) CacheRows(ctx context.Context) ([]CRLCacheRow, error) {
|
||||
now := s.now()
|
||||
ids := s.issuerIDs()
|
||||
out := make([]CRLCacheRow, 0, len(ids))
|
||||
|
||||
for _, issuerID := range ids {
|
||||
row := CRLCacheRow{IssuerID: issuerID}
|
||||
|
||||
entry, err := s.cacheRepo.Get(ctx, issuerID)
|
||||
if err != nil {
|
||||
// One issuer's failure should not blank the whole response —
|
||||
// the GUI shows partial state and surfaces the per-issuer
|
||||
// error as a generation event.
|
||||
row.RecentEvents = []CRLCacheEvt{{
|
||||
StartedAt: now, Succeeded: false,
|
||||
Error: "cache lookup failed: " + err.Error(),
|
||||
}}
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
if entry == nil {
|
||||
out = append(out, row) // CachePresent stays false
|
||||
continue
|
||||
}
|
||||
|
||||
row.CachePresent = true
|
||||
row.CRLNumber = entry.CRLNumber
|
||||
row.ThisUpdate = &entry.ThisUpdate
|
||||
row.NextUpdate = &entry.NextUpdate
|
||||
row.GeneratedAt = &entry.GeneratedAt
|
||||
row.GenerationDurMs = entry.GenerationDuration.Milliseconds()
|
||||
row.RevokedCount = entry.RevokedCount
|
||||
row.IsStale = entry.IsStale(now)
|
||||
|
||||
// Most-recent N generation events for ops grep.
|
||||
evts, err := s.cacheRepo.ListGenerationEvents(ctx, issuerID, s.eventLimit)
|
||||
if err == nil {
|
||||
row.RecentEvents = make([]CRLCacheEvt, 0, len(evts))
|
||||
for _, e := range evts {
|
||||
row.RecentEvents = append(row.RecentEvents, CRLCacheEvt{
|
||||
StartedAt: e.StartedAt,
|
||||
DurationMs: e.Duration.Milliseconds(),
|
||||
Succeeded: e.Succeeded,
|
||||
CRLNumber: e.CRLNumber,
|
||||
RevokedCount: e.RevokedCount,
|
||||
Error: e.Error,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ AdminCRLCacheService = (*AdminCRLCacheServiceImpl)(nil)
|
||||
|
||||
// _ silences the unused-import warning if domain pulls in only via
|
||||
// type aliases; the explicit reference here means the import is
|
||||
// intentional even when the file's other symbols don't reference it.
|
||||
var _ = domain.CRLGenerationEvent{}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// fakeAdminCRLCacheService is the test stub for the
|
||||
// AdminCRLCacheService interface — lets us exercise gate behavior
|
||||
// (admin / non-admin / explicit-false) without spinning up a real
|
||||
// CRLCacheRepository or issuer registry.
|
||||
type fakeAdminCRLCacheService struct {
|
||||
called bool
|
||||
rows []CRLCacheRow
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAdminCRLCacheService) CacheRows(_ context.Context) ([]CRLCacheRow, error) {
|
||||
f.called = true
|
||||
return f.rows, f.err
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_NonAdmin_Returns403 — M-003-pattern central
|
||||
// gate test. A caller without an admin-tagged context must be
|
||||
// rejected with HTTP 403, and the service layer must never see
|
||||
// the request (no enumeration of issuer set / cache state).
|
||||
func TestAdminCRLCache_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.called {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminExplicitFalse_Returns403 pins the
|
||||
// AdminKey-present-but-false case. Without this, a regression to
|
||||
// "key missing == deny, key present == allow" would silently grant
|
||||
// a false flag to any caller that managed to set the context value.
|
||||
func TestAdminCRLCache_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.called {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminPermitted_ForwardsActor confirms the
|
||||
// happy path: an admin-tagged context reaches the service and the
|
||||
// response shape is what the GUI expects (cache_rows / row_count /
|
||||
// generated_at). The actor-forwarding aspect of M-002 doesn't apply
|
||||
// here — this is a read-only endpoint with no audit-event side
|
||||
// effect — but the test name matches the M008 triplet convention so
|
||||
// the regression scanner finds it.
|
||||
func TestAdminCRLCache_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{
|
||||
rows: []CRLCacheRow{
|
||||
{IssuerID: "iss-a", CachePresent: true, CRLNumber: 1},
|
||||
{IssuerID: "iss-b", CachePresent: false},
|
||||
},
|
||||
}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
if !svc.called {
|
||||
t.Fatal("service was not invoked for admin caller")
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if rc, ok := resp["row_count"].(float64); !ok || rc != 2 {
|
||||
t.Errorf("row_count = %v, want 2", resp["row_count"])
|
||||
}
|
||||
if _, ok := resp["cache_rows"].([]any); !ok {
|
||||
t.Errorf("cache_rows missing or wrong shape: %v", resp["cache_rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_RejectsNonGetMethod pins the method gate.
|
||||
// Companion to the admin gate — both must fire to satisfy the
|
||||
// admin-only-GET contract.
|
||||
func TestAdminCRLCache_RejectsNonGetMethod(t *testing.T) {
|
||||
h := NewAdminCRLCacheHandler(&fakeAdminCRLCacheService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405 for POST, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_PropagatesServiceError surfaces 500 when the
|
||||
// service errors. Pins the failure-path response shape so future
|
||||
// refactors don't accidentally swallow errors as 200.
|
||||
func TestAdminCRLCache_PropagatesServiceError(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{err: errors.New("db down")}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on service error, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||
var AdminGatedHandlers = map[string]string{
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
}
|
||||
|
||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||
|
||||
@@ -122,6 +122,10 @@ type HandlerRegistry struct {
|
||||
// cmd/server/main.go so probes and rollout systems can read build
|
||||
// identity without Bearer credentials. See handler/version.go.
|
||||
Version handler.VersionHandler
|
||||
// AdminCRLCache handles GET /api/v1/admin/crl/cache. Bundle CRL/OCSP-
|
||||
// Responder Phase 5 — admin-gated ops surface for the
|
||||
// scheduler-driven CRL pre-generation pipeline.
|
||||
AdminCRLCache handler.AdminCRLCacheHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -287,6 +291,11 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("GET /api/v1/audit", http.HandlerFunc(reg.Audit.ListAuditEvents))
|
||||
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(reg.Audit.GetAuditEvent))
|
||||
|
||||
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the
|
||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||
// the handler (M-003 pattern); non-admin callers get 403.
|
||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||
|
||||
// Notifications routes: /api/v1/notifications
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
||||
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(reg.Notifications.GetNotification))
|
||||
|
||||
Reference in New Issue
Block a user