mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:01:34 +00:00
d473398aba
Bundle 1 / Phase 3 (primitive ship): the load-bearing RBAC middleware factory plus its dependencies. Handler conversion sweep (5 admin files: bulk_revocation.go, admin_crl_cache.go, admin_scep_intune.go, admin_est.go, intermediate_ca.go) + m008_admin_gate_test.go registry update is Phase 3.5 follow-on; this commit ships the primitive so 3.5 is mechanical. New context keys (internal/auth/context.go): ActorIDKey, ActorTypeKey, TenantIDKey alongside the legacy UserKey + AdminKey. New helpers GetActorID / GetActorType / GetTenantID with safe fallbacks (UserKey for actor id, ActorTypeAPIKey for missing type, DefaultTenantID for missing tenant). Constants DemoAnonActorID + ActorTypeAPIKey + ActorTypeAnonymous mirror internal/domain/auth without an import cycle. RequirePermission factory (internal/auth/require_permission.go): wraps a handler and gates it behind a named permission. 401 when no actor, 403 when actor lacks permission, 500 on repository error. Skips the gate entirely for protocol endpoints (ACME / SCEP / EST / OCSP / CRL) per the audit's Category F do-not-gate allowlist. PermissionChecker is an interface so internal/auth doesn't depend on internal/service/auth (cmd/server wires the concrete Authorizer at startup). HasPermission is the imperative variant for handlers that branch behaviour rather than 403'ing. ScopeFunc closure extracts the scope type + id from the request for per-resource gating. Protocol-endpoint allowlist (internal/auth/protocol_endpoints.go): IsProtocolEndpoint matches /acme, /scep, /.well-known/est, /.well-known/pki/ocsp, /.well-known/pki/crl prefixes. Adding a new protocol endpoint MUST update this list and add a parallel test. Demo-mode synthetic admin (internal/auth/middleware.go::NewDemoModeAuth): when CERTCTL_AUTH_TYPE=none is configured, this middleware injects ActorID=actor-demo-anon, ActorType=Anonymous, TenantID=t-default, plus the legacy UserKey + AdminKey for back-compat with existing handlers. The synthetic actor's admin-role grant is seeded by migration 000029 so RequirePermission resolves through the JOIN like any other actor. cmd/server startup wires this middleware only when none-mode is configured. API-key middleware extension: NewAuthWithNamedKeys now populates the new keys (ActorIDKey, ActorTypeKey=APIKey, TenantIDKey=t-default) alongside UserKey + AdminKey on every successful Bearer match. Existing handlers continue to read UserKey / IsAdmin until the Phase 3.5 sweep converts them to RequirePermission. Test coverage: TestRequirePermission_NoActorReturns401, TestRequirePermission_GrantedActorReaches200, TestRequirePermission_DeniedActorReturns403, TestRequirePermission_CheckerErrorReturns500, TestRequirePermission_ProtocolEndpointBypassesGate (covers all 5 prefixes), TestRequirePermission_ScopeFnExtractsResourceID, TestIsProtocolEndpoint_PrefixesOnly, TestNewDemoModeAuth_InjectsSyntheticActor, TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys. fakeChecker pins the contract without a database. Phase 3.5 follow-on (NOT in this commit): convert each of the 5 admin handlers from auth.IsAdmin checks to auth.RequirePermission middleware in router.go; update internal/api/handler/m008_admin_gate_test.go to track auth.RequirePermission call sites instead of (or alongside) auth.IsAdmin; pick the right permission per handler (cert.revoke for bulk_revocation, etc.). Each handler conversion needs the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) per M-008. Branch: dev/auth-bundle-1. Phase 2 was prior commit (service layer). Phase 3.5 (handler conversion) + Phase 4 (HTTP API) on the next session.
177 lines
6.2 KiB
Go
177 lines
6.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// AuthConfig holds configuration for the legacy NewAuth shim.
|
|
//
|
|
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
|
|
// removed because no JWT middleware ships with certctl (silent auth
|
|
// downgrade pre-G-1). The single source of truth for the allowed set
|
|
// lives at internal/config.AuthType / config.ValidAuthTypes(); prefer
|
|
// those constants over string literals when comparing.
|
|
//
|
|
// Bundle 2 will extend ValidAuthTypes() with "oidc"; Bundle 1 leaves
|
|
// the surface unchanged.
|
|
type AuthConfig struct {
|
|
Type string // "api-key" or "none" (see config.AuthType constants)
|
|
Secret string // The raw API key or comma-separated list of valid API keys
|
|
}
|
|
|
|
// NewAuthWithNamedKeys creates an authentication middleware that validates
|
|
// Bearer tokens against a set of named API keys. Each key carries a name
|
|
// (propagated as the actor via context) and an admin flag (consulted by
|
|
// authorization gates such as bulk revocation).
|
|
//
|
|
// When namedKeys is empty the returned middleware is a no-op pass-through,
|
|
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
|
|
// or more keys are provided, requests must include a matching Bearer token
|
|
// or they are rejected with 401.
|
|
//
|
|
// Bundle 1 Phase 3 extends Middleware with the RBAC primitive. This
|
|
// function continues to exist as the API-key validator; Phase 3 wraps it
|
|
// with the role lookup that populates the future ActorIDKey / RolesKey
|
|
// context values.
|
|
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
|
|
if len(namedKeys) == 0 {
|
|
return func(next http.Handler) http.Handler {
|
|
return next
|
|
}
|
|
}
|
|
|
|
// Pre-compute hashes of all valid keys for constant-time comparison.
|
|
type keyEntry struct {
|
|
hash string
|
|
name string
|
|
admin bool
|
|
}
|
|
var entries []keyEntry
|
|
for _, nk := range namedKeys {
|
|
entries = append(entries, keyEntry{
|
|
hash: HashAPIKey(nk.Key),
|
|
name: nk.Name,
|
|
admin: nk.Admin,
|
|
})
|
|
}
|
|
|
|
// Warn if only one key is configured in production mode
|
|
if len(entries) == 1 {
|
|
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
|
|
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Extract Bearer token
|
|
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
token := authHeader[7:]
|
|
tokenHash := HashAPIKey(token)
|
|
|
|
// Check against all valid keys using constant-time comparison
|
|
var matched *keyEntry
|
|
for i := range entries {
|
|
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
|
|
matched = &entries[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if matched == nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Store the authenticated identity and admin flag in context.
|
|
// Bundle 1 Phase 0: legacy UserKey + AdminKey for back-compat.
|
|
// Bundle 1 Phase 3: new ActorIDKey + ActorTypeKey + TenantIDKey
|
|
// for RBAC-aware downstream code (RequirePermission, etc.).
|
|
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
|
|
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
|
|
ctx = context.WithValue(ctx, ActorIDKey{}, matched.name)
|
|
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
|
|
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// NewDemoModeAuth returns a middleware that injects the synthetic
|
|
// `actor-demo-anon` identity into every request context. Used when
|
|
// CERTCTL_AUTH_TYPE=none is configured (the demo path) so that
|
|
// RBAC-gated handlers see an admin-equivalent caller without operator
|
|
// configuration.
|
|
//
|
|
// The synthetic actor is seeded by migration 000029_rbac.up.sql with
|
|
// the admin role at global scope, so RequirePermission resolves
|
|
// every gated request as an admin. The reserved-actor guard in the
|
|
// service layer prevents the API from accidentally mutating this
|
|
// actor's role assignments.
|
|
//
|
|
// Production deployments MUST NOT use this middleware. The cmd/server
|
|
// startup wires it only when CERTCTL_AUTH_TYPE=none is explicitly
|
|
// configured.
|
|
func NewDemoModeAuth() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
ctx = context.WithValue(ctx, UserKey{}, DemoAnonActorID)
|
|
ctx = context.WithValue(ctx, AdminKey{}, true)
|
|
ctx = context.WithValue(ctx, ActorIDKey{}, DemoAnonActorID)
|
|
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAnonymous)
|
|
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// NewAuth is a legacy shim that converts a comma-separated Secret list into
|
|
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
|
|
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
|
|
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
|
|
// rather than the old hardcoded "api-key-user" so audit events carry
|
|
// meaningful identity even on the legacy path.
|
|
//
|
|
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
|
|
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
|
if cfg.Type == "none" {
|
|
return func(next http.Handler) http.Handler {
|
|
return next
|
|
}
|
|
}
|
|
|
|
var namedKeys []NamedAPIKey
|
|
idx := 0
|
|
for _, k := range strings.Split(cfg.Secret, ",") {
|
|
k = strings.TrimSpace(k)
|
|
if k == "" {
|
|
continue
|
|
}
|
|
namedKeys = append(namedKeys, NamedAPIKey{
|
|
Name: fmt.Sprintf("legacy-key-%d", idx),
|
|
Key: k,
|
|
Admin: false,
|
|
})
|
|
idx++
|
|
}
|
|
return NewAuthWithNamedKeys(namedKeys)
|
|
}
|