ZDDC/zddc/internal/handler/middleware.go
ZDDC 2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00

302 lines
11 KiB
Go

package handler
import (
"context"
"errors"
"net/http"
"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 <token>` — 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)
}
// PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) 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(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. The `elevated` field
// records whether the caller had opted into admin powers for
// this request — sudo-style. Surfacing it in the audit stream
// lets forensics distinguish "admin acting as a normal user"
// from "admin exercising authority" when reviewing a
// destructive action.
email := EmailFromContext(r)
if email == "" {
email = "anonymous"
}
elevated := ElevatedFromContext(r)
args := []any{
"ts", start.Format(time.RFC3339),
"email", email,
"elevated", elevated,
"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...)
}
})
}