feat(handler): audit log records active_admin alongside elevated

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 09:26:13 -05:00
parent 6c818648ca
commit a85b25ce08
3 changed files with 74 additions and 13 deletions

View file

@ -243,7 +243,7 @@ func main() {
if useTLS { if useTLS {
inner = handler.HSTSMiddleware(inner) inner = handler.HSTSMiddleware(inner)
} }
inner = handler.AccessLogMiddleware(auditLogger, inner) inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, tokens, inner) inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
mux.Handle("/", inner) mux.Handle("/", inner)
@ -369,7 +369,7 @@ func runClient(cfg config.Config) {
if useTLS { if useTLS {
inner = handler.HSTSMiddleware(inner) inner = handler.HSTSMiddleware(inner)
} }
inner = handler.AccessLogMiddleware(auditLogger, inner) inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/", inner) mux.Handle("/", inner)

View file

@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -165,6 +167,56 @@ func WithElevation(ctx context.Context, elevated bool) context.Context {
return context.WithValue(ctx, ElevatedKey, elevated) 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 // PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin // its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call // 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 // 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. // stderr stream — useful when stderr is not journald-captured (e.g.
// container logging where the orchestrator drops stderr after restarts). // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture request start time // Capture request start time
start := time.Now() start := time.Now()
@ -264,22 +316,31 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl
// Calculate duration // Calculate duration
durationMs := int(time.Since(start).Milliseconds()) durationMs := int(time.Since(start).Milliseconds())
// Get email + elevation from context. The `elevated` field // Get email + elevation from context. `elevated` records the
// records whether the caller had opted into admin powers for // per-request opt-in (sudo-style); `active_admin` says whether
// this request — sudo-style. Surfacing it in the audit stream // the elevated user actually held admin authority on the path
// lets forensics distinguish "admin acting as a normal user" // the request targeted — i.e., whether the single bypass
// from "admin exercising authority" when reviewing a // branch in policy.InternalDecider.Allow would have fired
// destructive action. // 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) email := EmailFromContext(r)
if email == "" { if email == "" {
email = "anonymous" email = "anonymous"
} }
elevated := ElevatedFromContext(r) elevated := ElevatedFromContext(r)
activeAdmin := activeAdminForRequest(cfg, r, elevated, email)
args := []any{ args := []any{
"ts", start.Format(time.RFC3339), "ts", start.Format(time.RFC3339),
"email", email, "email", email,
"elevated", elevated, "elevated", elevated,
"active_admin", activeAdmin,
"method", r.Method, "method", r.Method,
"path", requestedPath, "path", requestedPath,
"status", wrapped.status, "status", wrapped.status,

View file

@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads // Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated. // 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 := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com") req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK) 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) req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set. // Note: no X-Auth-Request-Email header set.
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
}) })
// Inverted order — the ORIGINAL buggy chain. // 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 := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com") 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"} 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 := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com") req.Header.Set("X-Auth-Request-Email", "bob@example.com")