From a85b25ce0862b90cfec92b6f9a909c7f0fde68b8 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 09:26:13 -0500 Subject: [PATCH] feat(handler): audit log records active_admin alongside elevated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access log now reports whether the elevated user actually held admin authority on the request's target path — i.e., whether the single bypass branch in policy.InternalDecider.Allow would have fired here. Three states fall out: elevated=false, active_admin=false: normal user elevated=true, active_admin=false: opted into admin but no admin grant on this path (subtree- admin out of scope) elevated=true, active_admin=true: admin authority active for this path — WORM/ACL bypass Implementation: AccessLogMiddleware gains a cfg parameter and calls activeAdminForRequest at log emission, walking the closest existing ancestor (same logic the file API uses to build its ACL chain). The cascade is mtime-cached upstream so the per-request cost is one map lookup in the common case. Audit value: a reviewer can spot at a glance whether a destructive write was authorized by ACL or by admin bypass. Plus "elevated=true active_admin=false" rows surface users who tried to elevate outside their actual scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 4 +- zddc/internal/handler/middleware.go | 75 +++++++++++++++++++++--- zddc/internal/handler/middleware_test.go | 8 +-- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index ec58ae8..e77b57d 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -243,7 +243,7 @@ func main() { if useTLS { inner = handler.HSTSMiddleware(inner) } - inner = handler.AccessLogMiddleware(auditLogger, inner) + inner = handler.AccessLogMiddleware(cfg, auditLogger, inner) inner = handler.ACLMiddleware(cfg, decider, tokens, inner) mux.Handle("/", inner) @@ -369,7 +369,7 @@ func runClient(cfg config.Config) { if useTLS { inner = handler.HSTSMiddleware(inner) } - inner = handler.AccessLogMiddleware(auditLogger, inner) + inner = handler.AccessLogMiddleware(cfg, auditLogger, inner) mux := http.NewServeMux() mux.Handle("/", inner) diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index b2ad37a..a54c545 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -4,6 +4,8 @@ import ( "context" "errors" "net/http" + "os" + "path/filepath" "strings" "time" @@ -165,6 +167,56 @@ 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. 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 +// false 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) bool { + if !elevated || email == "" || email == "anonymous" { + return false + } + 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 false + } + return zddc.IsAdminForChain(chain, email, false) + } + abs := filepath.Join(cfg.Root, filepath.FromSlash(rel)) + if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { + return false + } + 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 false + } + return zddc.IsAdminForChain(chain, email, false) +} + // 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 @@ -244,7 +296,7 @@ func HSTSMiddleware(next http.Handler) http.Handler { // 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 { +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() @@ -264,22 +316,31 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl // 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. + // 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) + activeAdmin := activeAdminForRequest(cfg, r, elevated, email) args := []any{ "ts", start.Format(time.RFC3339), "email", email, "elevated", elevated, + "active_admin", activeAdmin, "method", r.Method, "path", requestedPath, "status", wrapped.status, diff --git a/zddc/internal/handler/middleware_test.go b/zddc/internal/handler/middleware_test.go index 4a11424..a295795 100644 --- a/zddc/internal/handler/middleware_test.go +++ b/zddc/internal/handler/middleware_test.go @@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) { // Correct order: ACL is outer, AccessLog is inner. AccessLog reads // email from the context ACL populated. - chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop)) + chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) req.Header.Set("X-Auth-Request-Email", "alice@example.com") @@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) { w.WriteHeader(http.StatusOK) }) - chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop)) + chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) // Note: no X-Auth-Request-Email header set. @@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) { }) // Inverted order — the ORIGINAL buggy chain. - chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop)) + chain := AccessLogMiddleware(cfg, nil, ACLMiddleware(cfg, nil, nil, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) req.Header.Set("X-Auth-Request-Email", "alice@example.com") @@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) { }) cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} - chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop)) + chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop)) req := httptest.NewRequest(http.MethodGet, "/some/path", nil) req.Header.Set("X-Auth-Request-Email", "bob@example.com")