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