mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
auth-bundle-1 Phase 6-7-8: bootstrap path + scope-down CLI + auditor-role split
# Phase 6 — day-0 admin bootstrap * internal/auth/bootstrap/ (new package): Strategy interface + EnvTokenStrategy with constant-time compare, one-shot consumption via sync.Mutex, optional admin-existence probe. Bundle 2's OIDC- first-admin will plug in alongside as an alternate Strategy. * BootstrapService.ValidateAndMint: validates the operator's CERTCTL_BOOTSTRAP_TOKEN, mints a 32-byte (64-hex-char) random API key value, persists the SHA-256 hash to api_keys, grants r-admin via actor_roles, AddHashed's the runtime keystore so the just- minted key authenticates the next request without restart, and records bootstrap.consume to the audit trail with category=auth. * internal/auth/keystore.go (new): KeyStore interface + StaticKeyStore (immutable env-var-only path) + MutableKeyStore (env-var keys + DB-loaded api_keys + runtime AddHashed). The auth middleware now consumes a KeyStore so the bootstrap path can extend the lookup table at runtime. * migrations/000031_api_keys.up/down.sql: api_keys table with (id, name UNIQUE, key_hash UNIQUE, tenant_id, admin, created_by, created_at, expires_at, last_used_at). Idempotent. * /v1/auth/bootstrap GET (probe) + POST (mint) — auth-exempt. Both routes documented in api/openapi.yaml + AuthExemptRouterRoutes allowlist updated. The token never leaves internal/auth/bootstrap; the minted plaintext key flows only into the HTTP response body. * Startup warning emitted when CERTCTL_BOOTSTRAP_TOKEN is set AND admin actors already exist (config drift signal). * Tests: 4 strategy invariants (empty token born disabled, wrong token=ErrInvalidToken without consumption, one-shot consumption, admin-exists closes path), 5 service tests (happy path + actor- name validation + propagation of strategy errors + nil-deps guard + 32-byte entropy budget), 8 HTTP-handler tests (status 201/410/401/400 mapping + token-leak hygiene scan of slog + audit details + Location header). Token-leak test redirects slog.Default to a buffer for the test scope. # Phase 7 — API-key migration + scope-down CLI * GET /v1/auth/keys handler + service method ListKeys backed by ActorRoleRepository.ListDistinctActors. Returns one row per (actor_id, actor_type) pair with the slice of role IDs they hold. Permission: auth.role.list. * internal/cli/auth_scope_down.go: AuthListKeys, AuthScopeDown (interactive), AuthScopeDownNonInteractive (JSON config), AuthScopeDownSuggest (--suggest with optional --apply). The synthetic actor-demo-anon is filtered out of every interactive / bulk path; non-interactive flow logs and skips it explicitly. * SuggestRoleFromAuditEvents (pure function): walks 30 days of audit events per actor and returns the narrowest matching role (admin / mcp / viewer / agent / operator) plus a one-line reason. Classification: any admin-shaped action wins; otherwise all-MCP → mcp; all-read-only → viewer; all-agent-shaped → agent; otherwise operator. Test table pins all six classifications. * CLI subcommand tree extended: 'auth keys list' + 'auth keys scope-down [--non-interactive <cfg>] [--suggest [--apply]]'. * CHANGELOG.md leads v2.1.0 with the SECURITY: AUDIT YOUR API KEYS call-out + four flow examples. # Phase 8 — auditor role + event_category column * migrations/000032_audit_category.up/down.sql: ALTER TABLE audit_events ADD COLUMN event_category TEXT NOT NULL DEFAULT 'cert_lifecycle' + CHECK constraint (cert_lifecycle/auth/config) + (event_category) and (event_category, timestamp DESC) indexes for the auditor-filter query path. WORM trigger from migration 000018 continues to enforce append-only at the DB layer (DDL is not blocked). * domain.AuditEvent gains EventCategory string (omitempty); domain.EventCategoryCertLifecycle / Auth / Config constants. * AuditService.RecordEventWithCategory sibling of RecordEvent; legacy callers stay on RecordEvent (defaults to cert_lifecycle). Auth callers (RoleService, ActorRoleService, BootstrapService) switched to RecordEventWithCategory(..., 'auth', ...). * GET /v1/audit?category=<cat>: handler accepts the optional query param, validates against the enum (400 on invalid value), dispatches through ListAuditEventsByCategory. OpenAPI updated with the new query param + AuditEvent.event_category schema. * Postgres AuditRepository.Create now writes event_category; AuditRepository.List filters on it; AuditFilter.EventCategory gates the WHERE clause. * Tests: 5 audit-category-filter HTTP tests (dispatch routing, back-compat fallback, 400 for invalid values, all 3 enum values accepted, page+category combine, JSON output surfaces the field). 3 auditor-role invariants (auditor holds exactly audit.read+audit.export, no mutating perms, disjoint from viewer except audit.read). # Cross-phase wiring * HandlerRegistry.Bootstrap field added; cmd/server/main.go wires the bootstrap service ahead of RegisterHandlers (extracted assembleNamedAPIKeys helper into auth_backfill.go, moved the keystore + bootstrap construction up alongside the auth repos). * AuthCheckResolver / AuthActorRoleService extended with ListKeys to satisfy the Phase 7 surface; existing fakes updated. * fakeAudit + mockAuditService stubs in tests gain RecordEventWithCategory + ListAuditEventsByCategory; existing tests untouched. # Verifications * gofmt -l: clean across every modified file. * go vet ./...: clean. * staticcheck across internal/auth + handler + router + cli + service + repository + cmd + domain: clean. * go test -short -count=1: green across every Bundle-1-touched package — internal/auth (incl. bootstrap), internal/api/handler, internal/api/router, internal/cli, internal/service/auth, internal/service, internal/domain/auth, internal/repository/postgres, cmd/server, cmd/cli, plus internal/scheduler, internal/api/middleware, cmd/agent, internal/mcp.
This commit is contained in:
@@ -14,6 +14,12 @@ import (
|
||||
type AuditService interface {
|
||||
ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, error)
|
||||
// ListAuditEventsByCategory (Bundle 1 Phase 8) returns audit
|
||||
// rows whose event_category column matches eventCategory.
|
||||
// eventCategory is one of "cert_lifecycle", "auth", "config";
|
||||
// empty string returns all categories. Used by the auditor role
|
||||
// (filtered to "auth" via /v1/audit?category=auth).
|
||||
ListAuditEventsByCategory(ctx context.Context, eventCategory string, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
}
|
||||
|
||||
// AuditHandler handles HTTP requests for audit event operations.
|
||||
@@ -27,7 +33,12 @@ func NewAuditHandler(svc AuditService) AuditHandler {
|
||||
}
|
||||
|
||||
// ListAuditEvents lists audit events.
|
||||
// GET /api/v1/audit?page=1&per_page=50
|
||||
// GET /api/v1/audit?page=1&per_page=50&category=auth
|
||||
//
|
||||
// Bundle 1 Phase 8 adds the optional `category` query parameter for
|
||||
// auditor-role filtering. Allowed values: cert_lifecycle, auth, config.
|
||||
// Unknown values surface 400 so misuse is caught loud (instead of
|
||||
// silently returning all rows).
|
||||
func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -49,8 +60,29 @@ func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
category := query.Get("category")
|
||||
if category != "" {
|
||||
switch category {
|
||||
case domain.EventCategoryCertLifecycle, domain.EventCategoryAuth, domain.EventCategoryConfig:
|
||||
// ok
|
||||
default:
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
"Invalid category — allowed: cert_lifecycle, auth, config",
|
||||
requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
events, total, err := h.svc.ListAuditEvents(r.Context(), page, perPage)
|
||||
var (
|
||||
events []domain.AuditEvent
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
if category != "" {
|
||||
events, total, err = h.svc.ListAuditEventsByCategory(r.Context(), category, page, perPage)
|
||||
} else {
|
||||
events, total, err = h.svc.ListAuditEvents(r.Context(), page, perPage)
|
||||
}
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 8 — audit category-filter HTTP behaviour.
|
||||
// =============================================================================
|
||||
|
||||
// TestListAuditEvents_Phase8_CategoryFilterDispatchesToService pins the
|
||||
// happy-path: ?category=auth routes through ListAuditEventsByCategory
|
||||
// with the right argument.
|
||||
func TestListAuditEvents_Phase8_CategoryFilterDispatchesToService(t *testing.T) {
|
||||
var capturedCategory string
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(category string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
capturedCategory = category
|
||||
return []domain.AuditEvent{
|
||||
{ID: "audit-1", Action: "auth.role.assign", EventCategory: domain.EventCategoryAuth},
|
||||
}, 1, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if capturedCategory != "auth" {
|
||||
t.Errorf("captured category = %q, want auth", capturedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents pins
|
||||
// that the legacy unfiltered path still routes through ListAuditEvents
|
||||
// (preserves back-compat).
|
||||
func TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents(t *testing.T) {
|
||||
listCalled := false
|
||||
listByCatCalled := false
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(_, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
listCalled = true
|
||||
return nil, 0, nil
|
||||
},
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
listByCatCalled = true
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if !listCalled {
|
||||
t.Errorf("ListAuditEvents not called for unfiltered request")
|
||||
}
|
||||
if listByCatCalled {
|
||||
t.Errorf("ListAuditEventsByCategory called unexpectedly for unfiltered request")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_RejectsUnknownCategory pins the 400 surface
|
||||
// for misuse. Allowed values are exactly cert_lifecycle/auth/config;
|
||||
// anything else surfaces a clear error rather than silently returning
|
||||
// every row.
|
||||
func TestListAuditEvents_Phase8_RejectsUnknownCategory(t *testing.T) {
|
||||
mockSvc := &mockAuditService{}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
for _, bad := range []string{"agent", "AUTH", "auth%20", "system"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+bad, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("category=%q got status %d, want 400", bad, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_AcceptsAllThreeCategories pins that each of
|
||||
// the three documented enum values dispatches without a 400.
|
||||
func TestListAuditEvents_Phase8_AcceptsAllThreeCategories(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
for _, cat := range []string{
|
||||
domain.EventCategoryCertLifecycle,
|
||||
domain.EventCategoryAuth,
|
||||
domain.EventCategoryConfig,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+cat, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("category=%s got status %d, want 200", cat, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_CategoryAndPageCombine confirms the query
|
||||
// parser respects both the page and category params concurrently.
|
||||
func TestListAuditEvents_Phase8_CategoryAndPageCombine(t *testing.T) {
|
||||
var capturedCategory string
|
||||
var capturedPage int
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(category string, page, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
capturedCategory = category
|
||||
capturedPage = page
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth&page=3", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if capturedCategory != "auth" || capturedPage != 3 {
|
||||
t.Errorf("captured (cat=%q page=%d), want (auth, 3)", capturedCategory, capturedPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_ResponseSurfacesEventCategory confirms the
|
||||
// JSON output carries the event_category field for downstream auditors.
|
||||
func TestListAuditEvents_Phase8_ResponseSurfacesEventCategory(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
return []domain.AuditEvent{
|
||||
{ID: "a1", Action: "auth.role.assign", EventCategory: "auth"},
|
||||
{ID: "a2", Action: "issuer.edit", EventCategory: "config"},
|
||||
}, 2, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
var resp struct {
|
||||
Data []domain.AuditEvent `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data) != 2 || resp.Data[0].EventCategory != "auth" || resp.Data[1].EventCategory != "config" {
|
||||
t.Errorf("event_category not surfaced in JSON: %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = context.Background // keep import even if other tests strip it
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// mockAuditService implements AuditService for testing.
|
||||
type mockAuditService struct {
|
||||
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
getFunc func(id string) (*domain.AuditEvent, error)
|
||||
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
listByCatFunc func(category string, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
getFunc func(id string) (*domain.AuditEvent, error)
|
||||
}
|
||||
|
||||
func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
@@ -26,6 +27,16 @@ func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int)
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditService) ListAuditEventsByCategory(_ context.Context, category string, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
if m.listByCatFunc != nil {
|
||||
return m.listByCatFunc(category, page, perPage)
|
||||
}
|
||||
if m.listFunc != nil {
|
||||
return m.listFunc(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
|
||||
if m.getFunc != nil {
|
||||
return m.getFunc(id)
|
||||
|
||||
@@ -58,6 +58,12 @@ type AuthActorRoleService interface {
|
||||
Revoke(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType, roleID string) error
|
||||
ListForActor(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error)
|
||||
EffectivePermissions(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error)
|
||||
// ListKeys (Bundle 1 Phase 7) returns every actor in the tenant
|
||||
// with at least one role grant. The CLI's `auth keys list` and
|
||||
// scope-down helper consume this. The synthetic actor-demo-anon
|
||||
// row is included; the CLI filters it out of the interactive
|
||||
// prompt loop.
|
||||
ListKeys(ctx context.Context, caller *authsvc.Caller) ([]repository.ActorWithRoles, error)
|
||||
}
|
||||
|
||||
// NewAuthHandler constructs an AuthHandler with the service-layer
|
||||
@@ -291,6 +297,39 @@ func (h AuthHandler) ListPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": out})
|
||||
}
|
||||
|
||||
// ListKeys handles GET /api/v1/auth/keys (Bundle 1 Phase 7).
|
||||
// Permission: auth.role.list. Returns every distinct actor in the
|
||||
// tenant with at least one role grant — the CLI's `auth keys list`
|
||||
// and scope-down flow consume this.
|
||||
func (h AuthHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
keys, err := h.actors.ListKeys(r.Context(), caller)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
type keyEntry struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
}
|
||||
out := make([]keyEntry, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, keyEntry{
|
||||
ActorID: k.ActorID,
|
||||
ActorType: string(k.ActorType),
|
||||
TenantID: k.TenantID,
|
||||
RoleIDs: k.RoleIDs,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out})
|
||||
}
|
||||
|
||||
// AddRolePermission handles POST /api/v1/auth/roles/{id}/permissions.
|
||||
func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||
)
|
||||
|
||||
// BootstrapHandler exposes the Bundle 1 Phase 6 day-0 admin path.
|
||||
//
|
||||
// Threat model (from cowork/auth-bundle-1-prompt.md): the control
|
||||
// plane comes up with no admin actors. The operator hands the
|
||||
// CERTCTL_BOOTSTRAP_TOKEN to a single curl call; the server mints
|
||||
// the first admin key and locks the door. No subsequent invocation
|
||||
// can mint another admin via this path — the strategy state and the
|
||||
// "admin already exists" probe both close it. After bootstrap the
|
||||
// operator manages keys via /v1/auth/keys/...
|
||||
//
|
||||
// Handler shape:
|
||||
//
|
||||
// GET /v1/auth/bootstrap → 200 {available:true|false}
|
||||
// POST /v1/auth/bootstrap → 201 {api_key, key_value, actor_id}
|
||||
//
|
||||
// The GET surface is intentionally probable from any caller; it
|
||||
// returns availability (no token, no admin probe) so the GUI and the
|
||||
// install one-liner can decide whether to render the bootstrap
|
||||
// affordance. The POST surface requires the bootstrap token and
|
||||
// returns the plaintext key value once.
|
||||
type BootstrapHandler struct {
|
||||
svc *bootstrap.Service
|
||||
}
|
||||
|
||||
// NewBootstrapHandler constructs a BootstrapHandler. svc may be nil
|
||||
// to disable both methods (handler returns 410 Gone on every call).
|
||||
func NewBootstrapHandler(svc *bootstrap.Service) BootstrapHandler {
|
||||
return BootstrapHandler{svc: svc}
|
||||
}
|
||||
|
||||
type bootstrapAvailableResponse struct {
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
type bootstrapRequest struct {
|
||||
Token string `json:"token"`
|
||||
ActorName string `json:"actor_name"`
|
||||
}
|
||||
|
||||
type bootstrapResponse struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
APIKeyID string `json:"api_key_id"`
|
||||
KeyValue string `json:"key_value"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Available is the GET probe. Returns {available: true} when the
|
||||
// strategy is callable AND no admin actors exist; otherwise {available:
|
||||
// false}. The endpoint never reveals the bootstrap token's existence
|
||||
// independently of admin actor state — the GUI uses this to decide
|
||||
// whether to render the "first-time setup" wizard.
|
||||
func (h BootstrapHandler) Available(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
available := false
|
||||
if h.svc != nil {
|
||||
ok, err := h.svc.Available(r.Context())
|
||||
if err == nil {
|
||||
available = ok
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, bootstrapAvailableResponse{Available: available})
|
||||
}
|
||||
|
||||
// Mint is the POST handler that consumes the token + creates the
|
||||
// first admin key.
|
||||
//
|
||||
// Status mapping:
|
||||
//
|
||||
// 410 Gone → strategy disabled (no token, admin exists, or one-shot already consumed)
|
||||
// 401 Unauthorized → token mismatch
|
||||
// 400 Bad Request → invalid actor_name
|
||||
// 201 Created → key minted; response carries the plaintext key value
|
||||
func (h BootstrapHandler) Mint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if h.svc == nil {
|
||||
// No service wired = endpoint disabled. Same status as the
|
||||
// "already consumed" path so callers can't differentiate
|
||||
// configuration from state.
|
||||
Error(w, http.StatusGone, "bootstrap endpoint disabled")
|
||||
return
|
||||
}
|
||||
var body bootstrapRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
body.ActorName = strings.TrimSpace(body.ActorName)
|
||||
result, err := h.svc.ValidateAndMint(r.Context(), body.Token, body.ActorName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, bootstrap.ErrDisabled):
|
||||
Error(w, http.StatusGone, "bootstrap endpoint disabled")
|
||||
case errors.Is(err, bootstrap.ErrInvalidToken):
|
||||
Error(w, http.StatusUnauthorized, "Invalid bootstrap token")
|
||||
case errors.Is(err, bootstrap.ErrInvalidActorName):
|
||||
Error(w, http.StatusBadRequest, "Invalid actor_name (3-64 chars, lowercase alnum + - + _)")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "Bootstrap failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, bootstrapResponse{
|
||||
ActorID: result.APIKey.Name,
|
||||
APIKeyID: result.APIKey.ID,
|
||||
KeyValue: result.KeyValue,
|
||||
CreatedAt: result.APIKey.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
Message: "Admin API key created. This is the only time the key value is shown — capture it now.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// In-memory fakes (copies of the bootstrap-package fakes; the package
|
||||
// boundary keeps the bootstrap-package tests independent).
|
||||
// =============================================================================
|
||||
|
||||
type stubMinter struct{ created []*authdomain.APIKey }
|
||||
|
||||
func (s *stubMinter) Create(_ context.Context, k *authdomain.APIKey) error {
|
||||
s.created = append(s.created, k)
|
||||
return nil
|
||||
}
|
||||
func (s *stubMinter) GetByName(_ context.Context, _ string) (*authdomain.APIKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type stubGranter struct{ calls []*authdomain.ActorRole }
|
||||
|
||||
func (s *stubGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
s.calls = append(s.calls, ar)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubAudit struct{ calls []map[string]interface{} }
|
||||
|
||||
func (s *stubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, _ string, _ string, _ string, _ string, details map[string]interface{}) error {
|
||||
s.calls = append(s.calls, details)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubKeyStore struct {
|
||||
mu sync.Mutex
|
||||
rows []string
|
||||
}
|
||||
|
||||
func (s *stubKeyStore) AddHashed(name, hash string, _ bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rows = append(s.rows, name+":"+hash)
|
||||
}
|
||||
|
||||
func sha(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newBootstrapHandlerWith(token string, probe bootstrap.AdminExistenceProbe) (BootstrapHandler, *stubMinter, *stubGranter, *stubAudit, *stubKeyStore) {
|
||||
strategy := bootstrap.NewEnvTokenStrategy(token, probe)
|
||||
minter := &stubMinter{}
|
||||
granter := &stubGranter{}
|
||||
audit := &stubAudit{}
|
||||
store := &stubKeyStore{}
|
||||
svc := bootstrap.NewService(strategy, minter, granter, audit, store, sha)
|
||||
return NewBootstrapHandler(svc), minter, granter, audit, store
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handler tests
|
||||
// =============================================================================
|
||||
|
||||
// TestBootstrapHandler_Mint_ValidTokenReturns201 is the happy path.
|
||||
// Plaintext key value present in the response body; only the hash is
|
||||
// persisted via the minter.
|
||||
func TestBootstrapHandler_Mint_ValidTokenReturns201(t *testing.T) {
|
||||
h, minter, granter, audit, store := newBootstrapHandlerWith("the-token", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp bootstrapResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.ActorID != "first-admin" {
|
||||
t.Errorf("actor_id = %q, want first-admin", resp.ActorID)
|
||||
}
|
||||
if resp.KeyValue == "" {
|
||||
t.Errorf("key_value missing from response")
|
||||
}
|
||||
if len(minter.created) != 1 || len(granter.calls) != 1 || len(audit.calls) != 1 || len(store.rows) != 1 {
|
||||
t.Errorf("side effects mismatch: minter=%d grants=%d audit=%d keystore=%d",
|
||||
len(minter.created), len(granter.calls), len(audit.calls), len(store.rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_WrongToken_401 pins the wrong-token mapping.
|
||||
func TestBootstrapHandler_Mint_WrongToken_401(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
body, _ := json.Marshal(map[string]string{"token": "wrong", "actor_name": "first-admin"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_TwiceReturns410 pins the one-shot
|
||||
// invariant. Second call after a successful first call returns 410
|
||||
// Gone, NOT 401 (which would suggest "wrong token, retry").
|
||||
func TestBootstrapHandler_Mint_TwiceReturns410(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
rec1 := httptest.NewRecorder()
|
||||
h.Mint(rec1, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec1.Code != http.StatusCreated {
|
||||
t.Fatalf("first call status = %d, want 201", rec1.Code)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.Mint(rec2, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec2.Code != http.StatusGone {
|
||||
t.Errorf("second call status = %d, want 410 Gone", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_AdminExists410 pins that the admin-
|
||||
// existence probe gates the endpoint. Operator forgets to unset
|
||||
// CERTCTL_BOOTSTRAP_TOKEN after onboarding → endpoint stays 410.
|
||||
func TestBootstrapHandler_Mint_AdminExists410(t *testing.T) {
|
||||
probe := func(_ context.Context) (bool, error) { return true, nil }
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Errorf("status = %d, want 410 Gone (admin already exists)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_NoTokenConfigured410 pins that an unset
|
||||
// CERTCTL_BOOTSTRAP_TOKEN closes the path (410), matching the
|
||||
// "endpoint disabled" semantics the prompt requires.
|
||||
func TestBootstrapHandler_Mint_NoTokenConfigured410(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "anything", "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Errorf("status = %d, want 410 Gone (no token configured)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_BadActorName_400 pins the actor-name
|
||||
// validation surface (charset, length).
|
||||
func TestBootstrapHandler_Mint_BadActorName_400(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
cases := []string{"", "AB", "has space", "Has-Caps"}
|
||||
for _, name := range cases {
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": name})
|
||||
rec := httptest.NewRecorder()
|
||||
// Each request consumes the strategy on success so we rebuild
|
||||
// per case.
|
||||
h2, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
h2.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("name=%q status = %d, want 400", name, rec.Code)
|
||||
}
|
||||
}
|
||||
_ = h
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Available_NoTokenSet pins the GET probe shape:
|
||||
// {available:false} when the token is unset.
|
||||
func TestBootstrapHandler_Available_NoTokenSet(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp bootstrapAvailableResponse
|
||||
_ = json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if resp.Available {
|
||||
t.Errorf("available=true with no token, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Available_TokenSetNoAdmin returns true.
|
||||
func TestBootstrapHandler_Available_TokenSetNoAdmin(t *testing.T) {
|
||||
probe := func(_ context.Context) (bool, error) { return false, nil }
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil))
|
||||
var resp bootstrapAvailableResponse
|
||||
_ = json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if !resp.Available {
|
||||
t.Errorf("available=false with token set + no admin, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_TokenLeakHygiene scans the slog logger output
|
||||
// after a happy-path mint. The bootstrap token MUST NOT appear in any
|
||||
// log line. Audit details, app logs, error wrappers — none of them
|
||||
// can contain the token.
|
||||
func TestBootstrapHandler_TokenLeakHygiene(t *testing.T) {
|
||||
const token = "extremely-secret-bootstrap-token-do-not-leak"
|
||||
|
||||
// Capture every slog write. Tests in this package (and the
|
||||
// upstream service package) currently use the global slog
|
||||
// default; we redirect it for the duration of this test.
|
||||
var logBuf bytes.Buffer
|
||||
origLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(origLogger)
|
||||
|
||||
h, _, _, audit, _ := newBootstrapHandlerWith(token, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
|
||||
if strings.Contains(logBuf.String(), token) {
|
||||
t.Errorf("bootstrap token leaked into slog output")
|
||||
}
|
||||
for i, c := range audit.calls {
|
||||
blob, _ := json.Marshal(c)
|
||||
if strings.Contains(string(blob), token) {
|
||||
t.Errorf("bootstrap token leaked into audit details[%d]: %s", i, blob)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("Location"), token) {
|
||||
t.Errorf("bootstrap token leaked into Location header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_BodyReadCapped guards against a bad-faith
|
||||
// caller posting a 1MB token field. The handler caps the request body
|
||||
// at 4KB; a 5KB body should fail to decode.
|
||||
func TestBootstrapHandler_Mint_BodyReadCapped(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("t", nil)
|
||||
huge := strings.Repeat("a", 5000)
|
||||
body := []byte(`{"token":"t","actor_name":"first-admin","filler":"` + huge + `"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
h.Mint(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("oversized body should yield 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// keep io reachable (some compiler runs strip unused imports during
|
||||
// AST refactors; explicit ref guards against that without producing a
|
||||
// real test side effect).
|
||||
var _ = io.Discard
|
||||
@@ -142,6 +142,18 @@ func (f *fakeAuthActorSvc) ListForActor(_ context.Context, _ *authsvc.Caller, _
|
||||
func (f *fakeAuthActorSvc) EffectivePermissions(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]repository.EffectivePermission, error) {
|
||||
return f.effective, nil
|
||||
}
|
||||
func (f *fakeAuthActorSvc) ListKeys(_ context.Context, _ *authsvc.Caller) ([]repository.ActorWithRoles, error) {
|
||||
out := make([]repository.ActorWithRoles, 0, len(f.roles))
|
||||
for _, ar := range f.roles {
|
||||
out = append(out, repository.ActorWithRoles{
|
||||
ActorID: ar.ActorID,
|
||||
ActorType: ar.ActorType,
|
||||
TenantID: ar.TenantID,
|
||||
RoleIDs: []string{ar.RoleID},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakePermChecker struct {
|
||||
check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
|
||||
Reference in New Issue
Block a user