package handler import ( "context" "net/http" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "log/slog" ) type contextKey string // EmailKey is the context key for the authenticated user's email. const EmailKey contextKey = "email" // DeciderKey is the context key for the request's policy decider. // Set by ACLMiddleware so handlers deep in the stack can issue policy // queries without taking the decider as an explicit parameter. Although // the decider is an app-wide singleton (not per-request state), routing // it through context keeps the call-site signatures stable across the // "swap internal evaluator for external OPA" plumbing change. const DeciderKey contextKey = "policy-decider" // ACLMiddleware extracts the user email from the configured header and stores // it (along with the policy decider) in the request context. It does NOT // enforce ACL itself — each handler performs its own ACL check via // policy.AllowFromChain. func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { email := r.Header.Get(cfg.EmailHeader) // DEBUG-level header dump for diagnosing proxy / SSO header // passthrough. Off by default (LogLevel info); enable with // ZDDC_LOG_LEVEL=debug. Logs the configured header name, the // observed value at that name, and the full request header // map so an operator can see exactly what reached the binary. // Note: at debug level this also captures auth tokens, cookies, // and anything else upstream proxies forward — only enable in // trusted environments. slog.Debug("request headers", "configured", cfg.EmailHeader, "observed", email, "headers", r.Header) ctx := context.WithValue(r.Context(), EmailKey, email) if decider != nil { ctx = context.WithValue(ctx, DeciderKey, decider) } next.ServeHTTP(w, r.WithContext(ctx)) }) } // EmailFromContext extracts the user email from the request context. func EmailFromContext(r *http.Request) string { if v, ok := r.Context().Value(EmailKey).(string); ok { return v } return "" } // DeciderFromContext extracts the policy decider from the request // context. Returns the internal decider as a fallback if none was // installed — this matches the "no OPA configured" semantics and // keeps test setups that don't install ACLMiddleware functional. func DeciderFromContext(r *http.Request) policy.Decider { if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok { return v } return &policy.InternalDecider{} } // responseWriter wraps http.ResponseWriter to capture status code and bytes written. type responseWriter struct { http.ResponseWriter status int bytes int wrote bool } // WriteHeader records the status code and writes it to the underlying ResponseWriter. func (rw *responseWriter) WriteHeader(code int) { rw.status = code rw.wrote = true rw.ResponseWriter.WriteHeader(code) } // Write records the bytes written and writes to the underlying ResponseWriter. func (rw *responseWriter) Write(b []byte) (int, error) { n, err := rw.ResponseWriter.Write(b) rw.bytes += n return n, err } // HSTSMiddleware sets the Strict-Transport-Security response header, // instructing browsers to refuse plain-HTTP connections to this host // for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG // expectation; OWASP recommendation max-age >= 1 year). Use ONLY when // zddc-server is itself terminating TLS — when an upstream proxy // terminates, that proxy should set HSTS instead. // // includeSubDomains is set; preload is not (preload requires // pre-submitting the domain to the browser-vendor list — out of // scope for this server, and operators who want it can override // upstream). // // max-age = 31536000 = 365 days. func HSTSMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") next.ServeHTTP(w, r) }) } // AccessLogMiddleware logs a structured line per HTTP request after the // response is written. // // Always emits to slog.Default() (stderr) so server-lifecycle logs and // access logs share an output stream by default. // // If `auditLogger` is non-nil, the same structured fields are also written // to it. The intended caller wires up auditLogger with a JSON handler // pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog), // so an operator gets a persisted audit trail on disk in addition to the // stderr stream — useful when stderr is not journald-captured (e.g. // container logging where the orchestrator drops stderr after restarts). func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Capture request start time start := time.Now() // Wrap the ResponseWriter wrapped := &responseWriter{ResponseWriter: w, status: 200} // Serve the request next.ServeHTTP(wrapped, r) // Calculate duration durationMs := int(time.Since(start).Milliseconds()) // Get email from context email := EmailFromContext(r) if email == "" { email = "anonymous" } args := []any{ "ts", start.Format(time.RFC3339), "email", email, "method", r.Method, "path", r.URL.Path, "status", wrapped.status, "bytes", wrapped.bytes, "duration_ms", durationMs, } // Stderr stream (existing behavior). slog.Info("access", args...) // Audit file (when configured). Same fields, separate handler so // the file can be JSON-formatted regardless of stderr's handler. if auditLogger != nil { auditLogger.Info("access", args...) } }) }