feat(zddc): IsAdminForChain — single helper for admin authority

Pure cascade-walk admin check that replaces IsAdmin (root only) +
IsSubtreeAdmin (cascading) + CanEditZddc (strict-ancestor) under one
signature once callers migrate.

  IsAdminForChain(chain, email, excludeLeaf bool) bool

- chain is built for the request path, so subtree-admin scope falls
  out naturally (a chain rooted at /foo/ will only surface admins:
  entries at root and any level up to /foo/).
- email "" never matches (anonymous refusal).
- excludeLeaf=true drops the deepest level — implements the strict-
  ancestor rule for .zddc edits. At chain length 1 (root) the
  exclusion degenerates, preserving the bootstrap super-admin path.
- Elevation-INDEPENDENT — the caller wires Principal.Elevated around
  the result. Keeps this function a pure cascade query, testable
  without context plumbing.

Property tests pin: super-admin matches at depth; subtree admin
matches inside scope, blocked outside; excludeLeaf hides leaf admins
(self-elevation prevention); excludeLeaf at root falls back to root;
empty email refused; role references in admins resolve through the
chain; role defined at leaf is invisible above under excludeLeaf.

Old IsAdmin / IsSubtreeAdmin / CanEditZddc stay in place during the
migration — next commits move callers across, last commit removes
them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 09:14:44 -05:00
parent cfa7732183
commit 1c0777a847
2 changed files with 170 additions and 0 deletions

View file

@ -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
// /<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.
//
// 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:.<dir>.admins. ELEVATION-INDEPENDENT:

View file

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