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
// 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,

View file

@ -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.

View file

@ -1559,7 +1559,7 @@ body.is-elevated {
</svg>
<div class="header-title-group">
<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 class="header-right">

View file

@ -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
// /<dir>/.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

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