mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:41:30 +00:00
99a012e3be
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.
186 lines
6.6 KiB
Go
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{}
|