mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
7ff2e2de08
Phase 3.5 atomic conversion. The five legacy admin-gated handlers (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est, intermediate_ca) had their in-body auth.IsAdmin checks removed; the gate moved to router.go via auth.RequirePermission middleware wrapping each route. Non-admin operators with the right scoped permission can now reach these endpoints; legacy in-body admin checks no longer block them.
Migration 000030_rbac_admin_perms.up.sql ships five admin-only fine-grained permissions: cert.bulk_revoke, crl.admin, scep.admin, est.admin, ca.hierarchy.manage. All five are seeded into r-admin only; operator/viewer/agent/mcp/cli/auditor do not receive them by default. Operators can grant any of these to a custom role via the Phase 4 RBAC API. Idempotent + transaction-wrapped.
internal/domain/auth/validate.go::CanonicalPermissions extended with the five new entries so RoleService.AddPermission accepts them.
internal/api/router/router.go: HandlerRegistry gains a Checker field (auth.PermissionChecker). New rbacGate(checker, perm, handler) helper wraps a handler with auth.RequirePermission middleware; nil-checker fall-through preserves test/demo deployments without the RBAC stack. 12 admin routes wrapped: cert.bulk_revoke (POST /api/v1/certificates/bulk-revoke + POST /api/v1/est/certificates/bulk-revoke), crl.admin (GET /api/v1/admin/crl/cache), scep.admin (GET /api/v1/admin/scep/profiles + GET /api/v1/admin/scep/intune/stats + POST /api/v1/admin/scep/intune/reload-trust), est.admin (GET /api/v1/admin/est/profiles + POST /api/v1/admin/est/reload-trust), ca.hierarchy.manage (POST /api/v1/issuers/{id}/intermediates + GET /api/v1/issuers/{id}/intermediates + POST /api/v1/intermediates/{id}/retire + GET /api/v1/intermediates/{id}).
cmd/server/main.go: HandlerRegistry.Checker wired with the same authPermissionCheckerAdapter shim Phase 4 introduced for AuthHandler. Same adapter; one source of truth.
Handler bodies: removed eight in-body auth.IsAdmin checks across the 5 files. bulk_revocation.go's BulkRevoke + BulkRevokeEST, admin_crl_cache.go::ListCache, admin_scep_intune.go's three methods, admin_est.go's two methods, intermediate_ca.go's four methods. Replaced each with a comment naming the new gate location. Unused 'github.com/certctl-io/certctl/internal/auth' imports removed.
Test triplet rewrite: deleted obsolete _NonAdmin_Returns403 and _AdminExplicitFalse_Returns403 tests across 6 test files (5 handler tests + bulk_revocation_est_test.go) — they tested the now-removed in-body gate. _AdminPermitted_ForwardsActor tests stay intact: they pin the actor-passthrough invariant which is still relevant. Added internal/api/router/rbac_gate_integration_test.go with four router-level integration tests pinning the new gate: deny → 403 + handler not reached, permit → 200 + handler reached, nil-checker → fall-through, no-actor → 401.
M-008 admin-gate registry: AdminGatedHandlers map now empty (Phase 3.5 invariant: zero in-handler auth.IsAdmin call sites; only health.go's informational caller remains). m008_admin_gate_test.go retains the scan to enforce the invariant going forward; new admin-gated routes must wrap at router.go::rbacGate, not gate in-handler. Updated error message to direct future contributors to the new pattern.
Verifications: gofmt clean across all touched files; go vet ./... clean; go test -short across internal/auth, internal/service/auth, internal/api/handler, internal/api/router, cmd/server all green.
Branch: dev/auth-bundle-1. Commit chain: 99a012e (Phase 0 extract) -> 19497ee (Phase 1 schema + repo) -> bd54d5f (Phase 2 service) -> d473398 (Phase 3 primitive) -> b169f25 (Phase 4 + 5) -> THIS (Phase 3.5 conversion). Phase 6+ (bootstrap, scope-down, auditor, approval-bypass closure, GUI, docs) on subsequent sessions.
182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
|
|
|
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{}
|