Files
certctl/internal/api/handler/admin_crl_cache.go
T
shankar0123 69f860171e auth-bundle-1 Phase 0: extract internal/auth/ from middleware package
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home.

Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually.

Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser).

Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames.

Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested.

Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
2026-05-09 15:51:31 +00:00

186 lines
6.6 KiB
Go

package handler
import (
"context"
"net/http"
"time"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/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 !auth.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{}