mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
auth-bundle-2 Phase 6: session middleware + CSRF token plumbing +
chained-auth combinator + AuthInfo OIDC providers extension + 2 CI
guards (Bundle-1-compat + Bundle-1-to-2-upgrade)
Phase 6 wires the Phase 4 session service + Phase 5 OIDC handlers into
the request path. Three middlewares + one combinator land in
internal/auth/session/middleware.go:
1. SessionMiddleware reads `certctl_session` cookie, validates via
SessionService.Validate, populates the legacy UserKey/AdminKey
+ Phase 3 RBAC context keys (ActorIDKey/ActorTypeKey/TenantIDKey)
so downstream RequirePermission + audit-attribution see a
consistent caller. Best-effort UpdateLastSeen keeps the idle-
expiry sliding window fresh. CRITICALLY: never 401s on validate
failure — defers to the next middleware so the chained-auth
combinator can fall back to Bearer.
2. CSRFMiddleware gates state-changing methods (POST/PUT/DELETE/
PATCH) for session-authenticated requests. API-key actors are
EXEMPT (no session row in context => CSRF doesn't apply; they're
not browser-driven). Constant-time-compares SHA-256(X-CSRF-Token
header) against the session row's stored hash via
SessionService.ValidateCSRF. Mismatch returns 403.
3. ChainAuthSessionThenBearer is the load-bearing chained-auth
combinator: tries the session cookie first; on miss/invalid,
falls back to the API-key Bearer middleware; if neither
authenticates, 401. The composition uses bearerSkipIfAuthenticated
so a request with both a valid session AND a valid Bearer uses
the session (cookie wins per the Bundle 2 contract).
Middleware chain order in cmd/server/main.go (per Phase 6 spec):
RequestID → Logging → Recovery → CORS → RateLimit → AUTH (chained:
session → Bearer) → CSRF (state-changing only; API-key exempt) →
Audit → Handler
The chained authMiddleware replaces the bare Bundle-1 bearerMiddleware
at the chain entry point; csrfMiddleware lands immediately after so
session-authenticated requests pass through CSRF before audit. Both
new middlewares are pass-throughs when sessionService is nil
(pre-Phase-4 builds).
AuthInfo extension (Category E): GET /api/v1/auth/info now returns the
list of configured OIDC providers (id + display_name + login_url
where login_url = `/auth/oidc/login?provider=<id>`) so the GUI Login
page renders the correct "Sign in with X" buttons. Endpoint stays
auth-exempt; the providers list is public configuration. Wired via
HealthHandler.OIDCProvidersResolver + a new OIDCProvidersListResolver
projection interface; the cmd/server adapter
oidcProvidersListAdapter projects the postgres OIDCProviderRepository
into the public-safe shape. Resolver lookups are best-effort: failures
fall back to the minimal payload rather than 500-ing the GUI's auth
probe. Nil resolver preserves the pre-Phase-6 minimal shape so test
fixtures + no-db deploys keep compiling.
Bypass list preserved (Category E): the existing public-route
allowlist in router.AuthExemptRouterRoutes is preserved by virtue of
those routes registering via direct r.mux.Handle (they bypass the
entire chain). The protocol-endpoint allowlist (ACME/SCEP/EST/OCSP/
CRL) bypasses via cmd/server/main.go::buildFinalHandler URL-prefix
dispatch — those routes never reach the auth middleware at all. Both
preservations are pinned by the Bundle-1 compat CI guard below.
Tests (internal/auth/session/middleware_test.go):
All 7 Phase 6 spec-mandated middleware-chain tests pass:
1. Session cookie + correct CSRF → 200.
2. Session cookie + wrong CSRF → 403.
3. Bearer-only (no session) + no CSRF → 200 (API-key actors are
CSRF-exempt by design).
4. No cookie + no Bearer → 401.
5. Expired cookie + valid Bearer → fall back to Bearer succeeds.
6. Tampered cookie → 401 (no Bearer to fall back to).
7. Bypass-list awareness — state-changing method, no auth, no
session row → uniform 401 (NOT a CSRF 403; the CSRF check is
gated on session-row presence and never fires for unauth
requests).
Plus coverage-lift tests covering nil-service pass-through, safe-
methods bypass, SessionFromContext nil + populated, isStateChangingMethod
matrix, clientIPFromRequest variants (RemoteAddr / XFF first-hop /
XFF single / no-port), nil-bearer chain branches.
Coverage on internal/auth/session/middleware.go: 100% per-function
across the 9 entry points (SessionValidator interfaces +
NewSessionMiddleware + NewCSRFMiddleware + ChainAuthSessionThenBearer +
bearerSkipIfAuthenticated + SessionFromContext + isStateChangingMethod
+ clientIPFromRequest + lastIndexByte). Package coverage 94.9%.
Two new CI guards:
scripts/ci-guards/bundle-1-compat-regression.sh — Bundle-1-only
compat invariants. Static-source checks that protect the Bundle-1
path since spinning up docker-compose + running the integration
test suite is sandbox-infeasible:
1. SessionMiddleware MUST defer-to-next on missing/invalid cookie.
2. CSRFMiddleware MUST be pass-through on missing session row.
3. cmd/server/main.go MUST wire ChainAuthSessionThenBearer.
4. The 4 public OIDC routes MUST be in AuthExemptRouterRoutes.
5. AuthInfo MUST guard on OIDCProvidersResolver != nil.
scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh — Bundle-1 →
Bundle-2 upgrade invariants:
1. Migrations 000034..000037 use CREATE TABLE IF NOT EXISTS.
2. Migrations are wrapped in BEGIN; ... COMMIT;.
3. NO DROP TABLE / ALTER ... DROP COLUMN against any of the 19
protected Bundle-1 tables (api_keys, audit_events, certificates,
certificate_versions, profiles, issuers, targets, agents, jobs,
owners, teams, agent_groups, notifications, roles, permissions,
role_permissions, actor_roles, tenants, approvals,
intermediate_cas, issuance_approval_requests).
4. 000037 INSERTs use ON CONFLICT DO NOTHING (idempotent re-apply).
5. ChainAuthSessionThenBearer is wired (Bundle-1 Bearer keys
continue to authenticate post-upgrade).
6. Bootstrap handler is registered (fresh-deployment bootstrap
still works).
Both guards are sandbox-feasible static analysis. When the operator
gets a Linux VM with docker-in-docker, promote both to real `docker
compose up` integration tests against a v2.1.0 baseline DB dump.
Verifications: gofmt clean, go vet ./internal/auth/... ./internal/api/...
./cmd/server/... clean, go test -short -count=1 -race green across
internal/auth/session (94.9% coverage), internal/api/handler,
internal/api/router, no regressions in Bundle 1 packages, both new
ci-guards green.
This commit is contained in:
+54
-3
@@ -884,6 +884,12 @@ func main() {
|
||||
// erasure wrap around the repo so the handler layer doesn't have to
|
||||
// import internal/domain/auth or internal/repository/postgres.
|
||||
healthHandler.Resolver = authCheckResolverAdapter{repo: authActorRoleRepo}
|
||||
// Bundle 2 Phase 6 / Category E — wire the OIDC providers resolver
|
||||
// so GET /api/v1/auth/info returns the configured provider list
|
||||
// (id + display_name + login_url) for the GUI's Login page button
|
||||
// rendering. The shim adapts the postgres OIDCProviderRepository
|
||||
// to the handler's narrow OIDCProvidersListResolver projection.
|
||||
healthHandler.OIDCProvidersResolver = oidcProvidersListAdapter{repo: oidcProviderRepo}
|
||||
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
|
||||
// answers GET /api/v1/version with build identity (ldflags Version,
|
||||
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
|
||||
@@ -1747,13 +1753,25 @@ func main() {
|
||||
// HandlerRegistry can wire the bootstrap handler. The auth
|
||||
// middleware below reads from the same authKeyStore reference, so
|
||||
// runtime additions from bootstrap propagate without restart.
|
||||
var authMiddleware func(http.Handler) http.Handler
|
||||
var bearerMiddleware func(http.Handler) http.Handler
|
||||
switch config.AuthType(cfg.Auth.Type) {
|
||||
case config.AuthTypeNone:
|
||||
authMiddleware = auth.NewDemoModeAuth()
|
||||
bearerMiddleware = auth.NewDemoModeAuth()
|
||||
default:
|
||||
authMiddleware = auth.NewAuthWithKeyStore(authKeyStore)
|
||||
bearerMiddleware = auth.NewAuthWithKeyStore(authKeyStore)
|
||||
}
|
||||
// Auth Bundle 2 Phase 6 — chained-auth middleware. Tries the
|
||||
// `certctl_session` cookie first (sessionMW); on miss / invalid,
|
||||
// falls back to the API-key Bearer middleware. If neither
|
||||
// authenticates, 401. The session middleware is a pass-through
|
||||
// when sessionService is nil (pre-Bundle-2 builds).
|
||||
sessionMW := session.NewSessionMiddleware(sessionService)
|
||||
authMiddleware := session.ChainAuthSessionThenBearer(sessionMW, bearerMiddleware)
|
||||
// CSRF middleware — gates state-changing methods (POST/PUT/DELETE/
|
||||
// PATCH) for session-authenticated requests. API-key actors are
|
||||
// CSRF-exempt (not browser-driven). Pass-through when
|
||||
// sessionService is nil.
|
||||
csrfMiddleware := session.NewCSRFMiddleware(sessionService)
|
||||
_ = bootstrapHandler // referenced by HandlerRegistry above
|
||||
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
@@ -1802,7 +1820,10 @@ func main() {
|
||||
bodyLimitMiddleware,
|
||||
securityHeadersMiddleware,
|
||||
corsMiddleware,
|
||||
// Phase 6 chain: Auth (session-then-Bearer fallback) → CSRF
|
||||
// (state-changing only; API-key actors exempt) → Audit.
|
||||
authMiddleware,
|
||||
csrfMiddleware,
|
||||
auditMiddleware.Middleware,
|
||||
}
|
||||
|
||||
@@ -1824,7 +1845,10 @@ func main() {
|
||||
bodyLimitMiddleware,
|
||||
rateLimiter,
|
||||
corsMiddleware,
|
||||
// Phase 6 chain: Auth (session-then-Bearer fallback) → CSRF
|
||||
// (state-changing only; API-key actors exempt) → Audit.
|
||||
authMiddleware,
|
||||
csrfMiddleware,
|
||||
auditMiddleware.Middleware,
|
||||
}
|
||||
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
|
||||
@@ -2569,3 +2593,30 @@ func (a *sessionMinterAdapter) MintForUser(
|
||||
var (
|
||||
_ = oidcdomain.OIDCProvider{}
|
||||
)
|
||||
|
||||
// oidcProvidersListAdapter bridges the postgres OIDCProviderRepository
|
||||
// to handler.OIDCProvidersListResolver. The handler returns
|
||||
// []*OIDCProviderInfo (id + display_name + login_url) for the public-
|
||||
// safe GUI Login-page payload; the repo returns the full OIDCProvider
|
||||
// row. The adapter projects + maps the login_url shape that
|
||||
// /auth/oidc/login?provider=<id> expects. Auth Bundle 2 Phase 6 /
|
||||
// Category E.
|
||||
type oidcProvidersListAdapter struct {
|
||||
repo repository.OIDCProviderRepository
|
||||
}
|
||||
|
||||
func (a oidcProvidersListAdapter) List(ctx context.Context, tenantID string) ([]*handler.OIDCProviderInfo, error) {
|
||||
provs, err := a.repo.List(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*handler.OIDCProviderInfo, 0, len(provs))
|
||||
for _, p := range provs {
|
||||
out = append(out, &handler.OIDCProviderInfo{
|
||||
ID: p.ID,
|
||||
DisplayName: p.Name,
|
||||
LoginURL: "/auth/oidc/login?provider=" + p.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user