diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go
index 1d55fca..1cd3bec 100644
--- a/zddc/internal/zddc/admin.go
+++ b/zddc/internal/zddc/admin.go
@@ -30,6 +30,49 @@ 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.
+//
+// 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.
+//
+// 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.
+//
+// Replaces the trio of IsAdmin / IsSubtreeAdmin / CanEditZddc under a
+// single signature once callers migrate.
+func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
+ if email == "" {
+ return false
+ }
+ n := len(chain.Levels)
+ if n == 0 {
+ return false
+ }
+ end := n
+ if excludeLeaf && n > 1 {
+ end = n - 1
+ }
+ for i := 0; i < end; i++ {
+ for _, principal := range chain.Levels[i].Admins {
+ if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
// HasAnyAdminGrant reports whether email is named as an admin somewhere
// in the cascade — either the root's admins: list (super-admin) or any
// subtree-admin grant via paths:..admins. ELEVATION-INDEPENDENT:
diff --git a/zddc/internal/zddc/admin_test.go b/zddc/internal/zddc/admin_test.go
index d739fa5..4e5738a 100644
--- a/zddc/internal/zddc/admin_test.go
+++ b/zddc/internal/zddc/admin_test.go
@@ -419,3 +419,130 @@ func TestCanEditZddc(t *testing.T) {
})
}
}
+
+// TestIsAdminForChain pins the unified helper that replaces IsAdmin +
+// IsSubtreeAdmin + CanEditZddc once callers migrate. Each table entry
+// covers one property: cascade walk, role resolution scope, the
+// strict-ancestor rule for .zddc edits (excludeLeaf), the bootstrap
+// case for the root file, empty-email refusal.
+func TestIsAdminForChain(t *testing.T) {
+ cases := []struct {
+ name string
+ files map[string]string
+ dir string
+ email string
+ excludeLeaf bool
+ want bool
+ }{
+ {
+ name: "root super-admin matches at any depth",
+ files: map[string]string{"": "admins:\n - alice@example.com\n"},
+ dir: "a/b/c",
+ email: "alice@example.com",
+ want: true,
+ },
+ {
+ name: "subtree admin matches inside their subtree",
+ files: map[string]string{
+ "": "admins:\n - root@example.com\n",
+ "project": "admins:\n - alice@example.com\n",
+ },
+ dir: "project/sub",
+ email: "alice@example.com",
+ want: true,
+ },
+ {
+ name: "subtree admin does NOT match outside their subtree",
+ files: map[string]string{
+ "": "admins:\n - root@example.com\n",
+ "project1": "admins:\n - alice@example.com\n",
+ },
+ dir: "project2",
+ email: "alice@example.com",
+ want: false,
+ },
+ {
+ name: "excludeLeaf hides the leaf .zddc's own admins (self-elevation prevention)",
+ 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: false,
+ },
+ {
+ name: "excludeLeaf lets ancestor admins through (peer protection)",
+ 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: true,
+ },
+ {
+ name: "excludeLeaf at root falls back to the root admins (bootstrap)",
+ files: map[string]string{
+ "": "admins:\n - alice@example.com\n",
+ },
+ dir: "",
+ email: "alice@example.com",
+ excludeLeaf: true,
+ want: true,
+ },
+ {
+ name: "empty email never matches",
+ files: map[string]string{"": "admins:\n - \"*\"\n"},
+ dir: "",
+ email: "",
+ want: false,
+ },
+ {
+ name: "role membership in admins entry resolves through cascade",
+ files: map[string]string{
+ "": "roles:\n controllers:\n members: [bob@example.com]\n" +
+ "admins:\n - controllers\n",
+ },
+ dir: "",
+ email: "bob@example.com",
+ want: true,
+ },
+ {
+ name: "role defined at leaf is NOT visible above it under excludeLeaf",
+ files: map[string]string{
+ "": "admins:\n - leafrole\n",
+ "project": "roles:\n leafrole:\n members: [alice@example.com]\n",
+ },
+ dir: "project",
+ email: "alice@example.com",
+ excludeLeaf: true,
+ // admins entry at root references leafrole, but the role is
+ // defined at the leaf — under strict-ancestor rule (excludeLeaf),
+ // the leaf's role definition isn't visible. So the match fails.
+ want: false,
+ },
+ }
+
+ 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 := IsAdminForChain(chain, tc.email, tc.excludeLeaf); got != tc.want {
+ t.Errorf("IsAdminForChain(dir=%q, email=%q, excludeLeaf=%v) = %v, want %v",
+ tc.dir, tc.email, tc.excludeLeaf, got, tc.want)
+ }
+ })
+ }
+}