mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:22:07 +00:00
21aeed4f4e
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
185 lines
6.6 KiB
Go
185 lines
6.6 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
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{}
|