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:
shankar0123
2026-05-09 20:15:43 +00:00
parent 60a589ab96
commit 3ef45e2ad4
38 changed files with 3159 additions and 140 deletions
+34 -2
View File
@@ -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
+157
View File
@@ -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
+13 -2
View File
@@ -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)
+39
View File
@@ -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)
+127
View File
@@ -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.",
})
}
+275
View File
@@ -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
+12
View File
@@ -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)
+29 -4
View File
@@ -78,10 +78,12 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
}
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
@@ -131,6 +133,13 @@ type HandlerRegistry struct {
// PermissionService dependencies. Phase 5 ships the CLI mirror.
Auth handler.AuthHandler
// Bootstrap (Bundle 1 Phase 6) handles the day-0 admin path under
// /api/v1/auth/bootstrap. GET probes availability without revealing
// state; POST consumes CERTCTL_BOOTSTRAP_TOKEN once and mints the
// first admin API key. Both routes are auth-exempt (the endpoint
// itself authenticates via the bootstrap token).
Bootstrap handler.BootstrapHandler
// Checker is the load-bearing auth.PermissionChecker that
// auth.RequirePermission middleware uses to gate the legacy admin
// handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres
@@ -245,6 +254,21 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// Auth check endpoint (uses full middleware chain via r.Register)
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// Bundle 1 Phase 6 — bootstrap routes. Auth-exempt because the
// endpoint itself authenticates via the CERTCTL_BOOTSTRAP_TOKEN
// (see internal/auth/bootstrap). Both routes are pinned in the
// AuthExemptRouterRoutes allowlist above.
r.mux.Handle("GET /api/v1/auth/bootstrap", middleware.Chain(
http.HandlerFunc(reg.Bootstrap.Available),
middleware.CORS,
middleware.ContentType,
))
r.mux.Handle("POST /api/v1/auth/bootstrap", middleware.Chain(
http.HandlerFunc(reg.Bootstrap.Mint),
middleware.CORS,
middleware.ContentType,
))
// RBAC management routes (Bundle 1 Phase 4). Permission gates are
// enforced inside each handler via the service layer; the Phase 3
// auth.RequirePermission middleware factory will wrap these in a
@@ -259,6 +283,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole))
r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission))
r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission))
r.Register("GET /api/v1/auth/keys", http.HandlerFunc(reg.Auth.ListKeys))
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey))
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))