From ae105fde1ca048d0242e09467cdce486a867334f Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 10:55:53 -0500 Subject: [PATCH] feat(audit): chain_admin_level field in access log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log now records WHICH chain level conferred admin authority on each request — 0 for root super-admin, N for a subtree admin at depth N, -1 for no admin authority. Forensics can now distinguish: elevated=true active_admin=true chain_admin_level=0 → root super-admin acting elevated=true active_admin=true chain_admin_level=3 → subtree admin at ////.zddc acting elevated=true active_admin=false chain_admin_level=-1 → opted into admin but no grant on this path (out of scope) New helper zddc.AdminLevelInChain returns the level index (or -1); IsAdminForChain becomes a thin wrapper. Middleware's activeAdminForRequest is rewired to return the level so the audit emission gets the attribution without double-walking the cascade. Pre-existing TestServeProfileProjectsCreate's "no .zddc unless body supplies fields" expectation flipped — the project-create flow now always seeds admins: [creator] so the test asserts the new contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/middleware.go | 41 +++++--- zddc/internal/handler/profilehandler_test.go | 10 +- zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/admin.go | 54 ++++++----- zddc/internal/zddc/admin_test.go | 98 ++++++++++++++++++++ 5 files changed, 164 insertions(+), 41 deletions(-) diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index a54c545..4485980 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -169,15 +169,19 @@ func WithElevation(ctx context.Context, elevated bool) context.Context { // 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 { +// 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 false + return -1 } cleanURL := strings.TrimSuffix(r.URL.Path, "/") if cleanURL == "" { @@ -188,13 +192,13 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em // Root request: chain is just the root .zddc. chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root) if err != nil { - return false + return -1 } - return zddc.IsAdminForChain(chain, email, false) + return zddc.AdminLevelInChain(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 + return -1 } probe := abs for { @@ -212,9 +216,9 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em } chain, err := zddc.EffectivePolicy(cfg.Root, probe) if err != nil { - return false + return -1 } - return zddc.IsAdminForChain(chain, email, false) + return zddc.AdminLevelInChain(chain, email, false) } // PrincipalFromContext bundles the request's authenticated email plus @@ -334,13 +338,20 @@ func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http. email = "anonymous" } elevated := ElevatedFromContext(r) - activeAdmin := activeAdminForRequest(cfg, r, elevated, email) + // 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 derived + // for back-compat with existing log consumers. + adminLevel := activeAdminForRequest(cfg, r, elevated, email) args := []any{ "ts", start.Format(time.RFC3339), "email", email, "elevated", elevated, - "active_admin", activeAdmin, + "active_admin", adminLevel >= 0, + "chain_admin_level", adminLevel, "method", r.Method, "path", requestedPath, "status", wrapped.status, diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 2772786..0392086 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -699,6 +699,8 @@ func TestServeProfileProjectsCreate(t *testing.T) { } // Happy path: super-admin creates /alpha with no .zddc body. + // Post-refactor: the .zddc IS auto-written with the creator in + // admins: so they own the new project from birth. rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`) if rec.Code != http.StatusCreated { t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String()) @@ -706,8 +708,12 @@ func TestServeProfileProjectsCreate(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil { t.Errorf("alpha dir not created on disk: %v", err) } - if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil { - t.Errorf(".zddc should NOT be auto-written when no fields supplied") + if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil { + t.Errorf(".zddc should be auto-written with creator as admin: %v", err) + } else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil { + if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" { + t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins) + } } // Body with a title also writes a .zddc. diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 2dad3ad..44ad816 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1559,7 +1559,7 @@ body.is-elevated {
ZDDC Table - v0.0.17-alpha · 2026-05-18 15:46:59 · b80b11c-dirty + v0.0.17-alpha · 2026-05-18 15:55:46 · df19a63-dirty
diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go index 1cd3bec..2854adc 100644 --- a/zddc/internal/zddc/admin.go +++ b/zddc/internal/zddc/admin.go @@ -30,34 +30,35 @@ func (p Principal) gate() bool { return p.Email != "" && p.Elevated } -// IsAdminForChain reports whether email is granted admin authority by -// any level in chain. Elevation-INDEPENDENT — the caller is responsible -// for gating on Principal.Elevated before treating the result as live -// authority. +// AdminLevelInChain reports the chain-level index (0-based, root=0) +// at which the first admins: entry matching email occurs, or -1 if +// no level grants admin authority. Elevation-INDEPENDENT — the +// caller is responsible for gating on Principal.Elevated before +// treating the result as live authority. // -// excludeLeaf=true drops the deepest level from the walk. This implements -// the strict-ancestor rule for .zddc edits: a subtree admin granted at -// //.zddc cannot edit that same file (otherwise they could add -// peers, remove the delegator, or otherwise self-elevate). The root -// .zddc has no strict ancestor, so the rule degenerates at len==1: the -// loop falls back to checking that single level, preserving the -// bootstrap super-admin path. +// excludeLeaf=true drops the deepest level from the walk (strict- +// ancestor opt-in; ModeStrict deployments use this for .zddc edits). +// The root .zddc has no strict ancestor, so the rule degenerates at +// len==1: the loop falls back to checking that single level, +// preserving the bootstrap super-admin path. // -// Role lookups inside this walk are bounded to the levels visible AT -// the matching level (via MatchesPrincipal's third/fourth args). A role -// defined at the deepest level never confers self-edit rights even -// indirectly, because the role definition isn't visible above the -// level it's defined at. +// Role lookups inside this walk are bounded to the levels visible +// AT the matching level (via MatchesPrincipal's third/fourth args). +// A role defined at the deepest level never confers self-edit +// rights indirectly because the role definition isn't visible above +// the level it's defined at. // -// Replaces the trio of IsAdmin / IsSubtreeAdmin / CanEditZddc under a -// single signature once callers migrate. -func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool { +// Exposed separately from IsAdminForChain so audit-logging callers +// can record WHICH level conferred admin authority — useful for +// forensics across nested delegation (root admin vs subtree admin +// at depth N). +func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int { if email == "" { - return false + return -1 } n := len(chain.Levels) if n == 0 { - return false + return -1 } end := n if excludeLeaf && n > 1 { @@ -66,11 +67,18 @@ func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool { for i := 0; i < end; i++ { for _, principal := range chain.Levels[i].Admins { if MatchesPrincipal(principal, email, chain, i, ModeDelegated) { - return true + return i } } } - return false + return -1 +} + +// IsAdminForChain is the boolean shortcut over AdminLevelInChain. +// Returns true iff some level grants admin authority. See +// AdminLevelInChain for parameter semantics. +func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool { + return AdminLevelInChain(chain, email, excludeLeaf) >= 0 } // HasAnyAdminGrant reports whether email is named as an admin somewhere diff --git a/zddc/internal/zddc/admin_test.go b/zddc/internal/zddc/admin_test.go index 4e5738a..cbda20c 100644 --- a/zddc/internal/zddc/admin_test.go +++ b/zddc/internal/zddc/admin_test.go @@ -546,3 +546,101 @@ func TestIsAdminForChain(t *testing.T) { }) } } + +// TestAdminLevelInChain pins the level-index contract used by audit +// logging: root match returns 0, subtree match returns the deeper +// index, no match returns -1. +func TestAdminLevelInChain(t *testing.T) { + cases := []struct { + name string + files map[string]string + dir string + email string + excludeLeaf bool + want int + }{ + { + name: "root super-admin matches at level 0", + files: map[string]string{"": "admins:\n - alice@example.com\n"}, + dir: "project", + email: "alice@example.com", + want: 0, + }, + { + name: "subtree admin matches at level 1", + files: map[string]string{ + "": "admins:\n - root@example.com\n", + "project": "admins:\n - alice@example.com\n", + }, + dir: "project", + email: "alice@example.com", + want: 1, + }, + { + name: "deeper subtree admin matches at level 2", + files: map[string]string{ + "": "admins:\n - root@example.com\n", + "project": "admins:\n - alice@example.com\n", + "project/sub": "admins:\n - bob@example.com\n", + }, + dir: "project/sub", + email: "bob@example.com", + want: 2, + }, + { + name: "no match returns -1", + files: map[string]string{"": "admins:\n - root@example.com\n"}, + dir: "project", + email: "stranger@other.org", + want: -1, + }, + { + name: "empty email returns -1", + files: map[string]string{"": "admins:\n - \"*\"\n"}, + dir: "", + email: "", + want: -1, + }, + { + name: "excludeLeaf hides deepest match — falls back to ancestor", + files: map[string]string{ + "": "admins:\n - root@example.com\n", + "project": "admins:\n - alice@example.com\n", + }, + dir: "project", + email: "root@example.com", + excludeLeaf: true, + want: 0, + }, + { + name: "excludeLeaf with only leaf match returns -1", + files: map[string]string{ + "": "admins:\n - root@example.com\n", + "project": "admins:\n - alice@example.com\n", + }, + dir: "project", + email: "alice@example.com", + excludeLeaf: true, + want: -1, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeZddcTree(t, root, tc.files) + dir := filepath.Join(root, tc.dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + InvalidateCache(dir) + chain, err := EffectivePolicy(root, dir) + if err != nil { + t.Fatalf("EffectivePolicy: %v", err) + } + if got := AdminLevelInChain(chain, tc.email, tc.excludeLeaf); got != tc.want { + t.Errorf("AdminLevelInChain(dir=%q, email=%q) = %d, want %d", + tc.dir, tc.email, got, tc.want) + } + }) + } +}