feat(audit): chain_admin_level field in access log

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 /<project>/<sub>/<dir>/.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 10:55:53 -05:00
parent df19a63853
commit ae105fde1c
5 changed files with 164 additions and 41 deletions

View file

@ -169,15 +169,19 @@ func WithElevation(ctx context.Context, elevated bool) context.Context {
// activeAdminForRequest reports whether the elevated principal would // activeAdminForRequest reports whether the elevated principal would
// trigger the decider's admin-bypass branch on the chain at the // trigger the decider's admin-bypass branch on the chain at the
// request's target path. Best-effort: walks the closest existing // request's target path, AND which chain level conferred that
// ancestor (mirroring the file API's authorize logic) so a write // authority. Returned level is 0-based (root=0) when authority is
// targeting a not-yet-existing file still answers correctly. Returns // active, -1 otherwise.
// false on anonymous or un-elevated requests without touching the //
// filesystem. The cascade is mtime-cached upstream, so the per- // Best-effort: walks the closest existing ancestor (mirroring the
// request cost is one map lookup in the common case. // file API's authorize logic) so a write targeting a not-yet-
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) bool { // 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" { if !elevated || email == "" || email == "anonymous" {
return false return -1
} }
cleanURL := strings.TrimSuffix(r.URL.Path, "/") cleanURL := strings.TrimSuffix(r.URL.Path, "/")
if cleanURL == "" { 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. // Root request: chain is just the root .zddc.
chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root) chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
if err != nil { 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)) abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
return false return -1
} }
probe := abs probe := abs
for { for {
@ -212,9 +216,9 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em
} }
chain, err := zddc.EffectivePolicy(cfg.Root, probe) chain, err := zddc.EffectivePolicy(cfg.Root, probe)
if err != nil { 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 // PrincipalFromContext bundles the request's authenticated email plus
@ -334,13 +338,20 @@ func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.
email = "anonymous" email = "anonymous"
} }
elevated := ElevatedFromContext(r) 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{ args := []any{
"ts", start.Format(time.RFC3339), "ts", start.Format(time.RFC3339),
"email", email, "email", email,
"elevated", elevated, "elevated", elevated,
"active_admin", activeAdmin, "active_admin", adminLevel >= 0,
"chain_admin_level", adminLevel,
"method", r.Method, "method", r.Method,
"path", requestedPath, "path", requestedPath,
"status", wrapped.status, "status", wrapped.status,

View file

@ -699,6 +699,8 @@ func TestServeProfileProjectsCreate(t *testing.T) {
} }
// Happy path: super-admin creates /alpha with no .zddc body. // 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"}`) rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String()) 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 { if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
t.Errorf("alpha dir not created on disk: %v", err) t.Errorf("alpha dir not created on disk: %v", err)
} }
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil { if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil {
t.Errorf(".zddc should NOT be auto-written when no fields supplied") 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. // Body with a title also writes a .zddc.

View file

@ -1559,7 +1559,7 @@ body.is-elevated {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:46:59 · b80b11c-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:55:46 · df19a63-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -30,34 +30,35 @@ func (p Principal) gate() bool {
return p.Email != "" && p.Elevated return p.Email != "" && p.Elevated
} }
// IsAdminForChain reports whether email is granted admin authority by // AdminLevelInChain reports the chain-level index (0-based, root=0)
// any level in chain. Elevation-INDEPENDENT — the caller is responsible // at which the first admins: entry matching email occurs, or -1 if
// for gating on Principal.Elevated before treating the result as live // no level grants admin authority. Elevation-INDEPENDENT — the
// authority. // 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 // excludeLeaf=true drops the deepest level from the walk (strict-
// the strict-ancestor rule for .zddc edits: a subtree admin granted at // ancestor opt-in; ModeStrict deployments use this for .zddc edits).
// /<dir>/.zddc cannot edit that same file (otherwise they could add // The root .zddc has no strict ancestor, so the rule degenerates at
// peers, remove the delegator, or otherwise self-elevate). The root // len==1: the loop falls back to checking that single level,
// .zddc has no strict ancestor, so the rule degenerates at len==1: the // preserving the bootstrap super-admin path.
// 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 // Role lookups inside this walk are bounded to the levels visible
// the matching level (via MatchesPrincipal's third/fourth args). A role // AT the matching level (via MatchesPrincipal's third/fourth args).
// defined at the deepest level never confers self-edit rights even // A role defined at the deepest level never confers self-edit
// indirectly, because the role definition isn't visible above the // rights indirectly because the role definition isn't visible above
// level it's defined at. // the level it's defined at.
// //
// Replaces the trio of IsAdmin / IsSubtreeAdmin / CanEditZddc under a // Exposed separately from IsAdminForChain so audit-logging callers
// single signature once callers migrate. // can record WHICH level conferred admin authority — useful for
func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool { // forensics across nested delegation (root admin vs subtree admin
// at depth N).
func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int {
if email == "" { if email == "" {
return false return -1
} }
n := len(chain.Levels) n := len(chain.Levels)
if n == 0 { if n == 0 {
return false return -1
} }
end := n end := n
if excludeLeaf && n > 1 { if excludeLeaf && n > 1 {
@ -66,11 +67,18 @@ func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
for i := 0; i < end; i++ { for i := 0; i < end; i++ {
for _, principal := range chain.Levels[i].Admins { for _, principal := range chain.Levels[i].Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) { 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 // HasAnyAdminGrant reports whether email is named as an admin somewhere

View file

@ -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)
}
})
}
}