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:
parent
cfa7732183
commit
1c0777a847
2 changed files with 170 additions and 0 deletions
|
|
@ -30,6 +30,49 @@ func (p Principal) gate() bool {
|
||||||
return p.Email != "" && p.Elevated
|
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
|
// HasAnyAdminGrant reports whether email is named as an admin somewhere
|
||||||
// in the cascade — either the root's admins: list (super-admin) or any
|
// in the cascade — either the root's admins: list (super-admin) or any
|
||||||
// subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
|
// subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue