Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
374 lines
14 KiB
Go
374 lines
14 KiB
Go
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 <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)
|
|
}
|
|
|
|
// 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...)
|
|
}
|
|
})
|
|
}
|