package handler import ( "context" "errors" "net/http" "os" "path/filepath" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/auth" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "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" // ElevatedKey is the context key for the per-request elevation flag. // Drives zddc.Principal{Elevated} for admin-authority checks. Set by // ACLMiddleware according to the request's auth shape: // - Bearer tokens are implicitly elevated (machine clients can't // toggle a cookie; they're expected to act with the bearer's full // authority). // - Header-auth (browser) sessions elevate iff the request carries // a `zddc-elevate=1` cookie. The cookie is set/cleared by the // elevation toggle UI in the tool headers. const ElevatedKey contextKey = "elevated" // elevationCookieName is the cookie clients set to elevate their admin // powers for header-auth (browser) sessions. Value "1" = elevated; any // other value (or absent) = treat as non-admin even if the email is // named in admin lists. const elevationCookieName = "zddc-elevate" // ACLMiddleware extracts the user email 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. // // Two email sources, in order: // // 1. `Authorization: Bearer ` — if present, the token is // validated against the supplied auth.Store. On success, the // request runs as the token-file's email. On failure (invalid / // expired / no validator configured), the middleware short-circuits // with 401 — silently falling back to header-based auth would let // a misconfigured client masquerade as anonymous. // 2. Otherwise, the email is read from cfg.EmailHeader, exactly as // before. This is the upstream-auth-proxy path (oauth2-proxy, // Caddy auth, etc.) that injects the header on validated requests. // // `tokens` may be nil — deployments without the token system simply // reject any Bearer attempts with 401. This keeps Bearer-vs-no-Bearer // trust paths decoupled from the operator's choice to issue tokens. func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var email string var elevated bool if bearer := bearerToken(r); bearer != "" { if tokens == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } tok, err := tokens.Validate(bearer) if err != nil { if !errors.Is(err, auth.ErrInvalidToken) { slog.Warn("token validation error", "err", err) } http.Error(w, "Unauthorized", http.StatusUnauthorized) return } email = tok.Email // Bearer-token callers (CLI tools, scripts, mirror clients) // can't toggle a cookie — they're expected to operate with // the bearer's full authority. Implicit elevation keeps the // admin functions usable from the machine-client path. elevated = true } else { email = r.Header.Get(cfg.EmailHeader) // Browser sessions opt in to admin powers via the UI's // elevation toggle, which sets a `zddc-elevate=1` cookie. // Absent / any other value → treat as non-admin even when // the email is named in admin lists. if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" { elevated = true } } // 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) ctx = context.WithValue(ctx, ElevatedKey, elevated) if decider != nil { ctx = context.WithValue(ctx, DeciderKey, decider) } next.ServeHTTP(w, r.WithContext(ctx)) }) } // bearerToken returns the token value from the Authorization header // (case-insensitive on the "Bearer" scheme per RFC 6750), or the empty // string when no Bearer credential is present. func bearerToken(r *http.Request) string { v := r.Header.Get("Authorization") if v == "" { return "" } const prefix = "bearer " if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) { return "" } return strings.TrimSpace(v[len(prefix):]) } // 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 "" } // WithEmail returns a context carrying email under EmailKey. Test seam // for handlers that look up the authenticated user via EmailFromContext; // production traffic gets the same value injected by ACLMiddleware. func WithEmail(ctx context.Context, email string) context.Context { return context.WithValue(ctx, EmailKey, email) } // ElevatedFromContext reports whether the request has opted into its // admin powers. False for any request that wasn't tagged by // ACLMiddleware (including tests that don't install it), so admin // checks fail closed. func ElevatedFromContext(r *http.Request) bool { if v, ok := r.Context().Value(ElevatedKey).(bool); ok { return v } return false } // WithElevation returns a context carrying the elevation flag under // ElevatedKey. Test seam for the matching PrincipalFromContext lookup. func WithElevation(ctx context.Context, elevated bool) context.Context { return context.WithValue(ctx, ElevatedKey, elevated) } // activeAdminForRequest reports whether the elevated principal would // trigger the decider's admin-bypass branch on the chain at the // request's target path, AND which chain level conferred that // authority. Returned level is 0-based (root=0) when authority is // active, -1 otherwise. // // Best-effort: walks the closest existing ancestor (mirroring the // file API's authorize logic) so a write targeting a not-yet- // existing file still answers correctly. Returns -1 on anonymous // or un-elevated requests without touching the filesystem. The // cascade is mtime-cached upstream, so the per-request cost is one // map lookup in the common case. func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int { if !elevated || email == "" || email == "anonymous" { return -1 } cleanURL := strings.TrimSuffix(r.URL.Path, "/") if cleanURL == "" { cleanURL = "/" } rel := strings.TrimPrefix(cleanURL, "/") if rel == "" { // Root request: chain is just the root .zddc. chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root) if err != nil { return -1 } return zddc.AdminLevelInChain(chain, email) } abs := filepath.Join(cfg.Root, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { return -1 } probe := abs for { if info, err := os.Stat(probe); err == nil && info.IsDir() { break } if probe == cfg.Root { break } parent := filepath.Dir(probe) if parent == probe { break } probe = parent } chain, err := zddc.EffectivePolicy(cfg.Root, probe) if err != nil { return -1 } return zddc.AdminLevelInChain(chain, email) } // PrincipalFromContext bundles the request's authenticated email plus // its elevation flag into a zddc.Principal — the value type the admin // functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check // site replaces the previous ad-hoc email argument AND the previous // "did I remember to gate this?" review burden: the type system // enforces the gate by requiring a Principal value, which can only // come from ACLMiddleware-tagged contexts. func PrincipalFromContext(r *http.Request) zddc.Principal { return zddc.Principal{ Email: EmailFromContext(r), Elevated: ElevatedFromContext(r), } } // 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(cfg config.Config, 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() // Snapshot the as-typed URL path before downstream handlers may // rewrite it (case-insensitive canonicalization). The audit // stream records what the client actually sent, not the // resolved canonical form. requestedPath := r.URL.Path // 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 + elevation from context. `elevated` records the // per-request opt-in (sudo-style); `active_admin` says whether // the elevated user actually held admin authority on the path // the request targeted — i.e., whether the single bypass // branch in policy.InternalDecider.Allow would have fired // here. Surfacing both lets forensics distinguish: // elevated=false, active_admin=false: normal user // elevated=true, active_admin=false: tried to elevate but no // admin authority on this // path (subtree-admin // cooled by scope) // elevated=true, active_admin=true: admin authority active, // WORM/ACL bypassed email := EmailFromContext(r) if email == "" { email = "anonymous" } elevated := ElevatedFromContext(r) // adminLevel: 0-based chain index of the admins: entry that // conferred authority on this request, or -1 if no admin // authority applies. Lets forensics tell "root admin acted" // (level 0) apart from "subtree admin acted" (level N) apart // from "not admin" (-1). The active_admin bool is its // presence/absence projected to a boolean. adminLevel := activeAdminForRequest(cfg, r, elevated, email) args := []any{ "ts", start.Format(time.RFC3339), "email", email, "elevated", elevated, "active_admin", adminLevel >= 0, "chain_admin_level", adminLevel, "method", r.Method, "path", requestedPath, "status", wrapped.status, "bytes", wrapped.bytes, "duration_ms", durationMs, } if r.URL.Path != requestedPath { args = append(args, "resolved_path", r.URL.Path) } // 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...) } }) }