diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 869c694..13488c9 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -157,26 +157,34 @@ func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) { // ── Invariant 4 — .zddc strict-ancestor self-elevation prevention ───────── -func TestInvariant_SubtreeAdminCannotEditOwnSubtreeZddc(t *testing.T) { - // alice's authority comes from /Project-1/working/.zddc. She must - // NOT be able to edit that file — strict-ancestor rule prevents - // her from adding peers, removing the delegator, or otherwise - // self-elevating. +// Strict-ancestor was retired — a subtree admin owns their .zddc. +// These tests pin the post-change contract: an elevated admin +// granted in //.zddc CAN edit that file (add collaborators, +// adjust ACLs, even — accidentally — remove themselves). Footgun +// is recoverable via super-admin restore. + +func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) { cfg, _ := invariantsFixture(t) p := zddc.Principal{Email: "alice@example.com", Elevated: true} dir := filepath.Join(cfg.Root, "Project-1/working") - if zddc.CanEditZddc(cfg.Root, dir, p) { - t.Fatalf("subtree admin can edit own .zddc — strict-ancestor rule bypassed") + chain, err := zddc.EffectivePolicy(cfg.Root, dir) + if err != nil { + t.Fatalf("EffectivePolicy: %v", err) + } + if !zddc.IsAdminForChain(chain, p.Email, false) { + t.Fatalf("subtree admin lost authority to edit own .zddc — strict-ancestor wasn't supposed to apply") } } func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) { - // alice's authority over Project-1/working/ should let her create - // or edit .zddc files in deeper subtrees (e.g., per-user homes). cfg, _ := invariantsFixture(t) p := zddc.Principal{Email: "alice@example.com", Elevated: true} dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com") - if !zddc.CanEditZddc(cfg.Root, dir, p) { + chain, err := zddc.EffectivePolicy(cfg.Root, dir) + if err != nil { + t.Fatalf("EffectivePolicy: %v", err) + } + if !zddc.IsAdminForChain(chain, p.Email, false) { t.Fatalf("subtree admin blocked from editing deeper .zddc") } } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 10d94f2..2dad3ad 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1559,7 +1559,7 @@ body.is-elevated {
ZDDC Table - v0.0.17-alpha · 2026-05-18 15:25:14 · fd4f03a-dirty + v0.0.17-alpha · 2026-05-18 15:46:59 · b80b11c-dirty
diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 25c9dbc..59b7871 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -395,19 +395,21 @@ func AllowFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p z // when (and only when) the caller actually holds elevated admin // authority on this chain. // -// Strict-ancestor rule for .zddc edits: action == ActionAdmin signals -// a .zddc write, and IsAdminForChain is called with excludeLeaf=true -// so the leaf .zddc's own admins entry cannot authorize editing the -// file that grants it. Other actions use the full chain walk. -// -// Use this entry point in write-path handlers (file API, plan-review, -// accept-transmittal). Read-path callers that don't need admin -// bypass can stay on AllowActionFromChain / AllowFromChain — they -// implicitly leave IsActiveAdmin=false. +// Strict-ancestor rule: NOT applied by default. A subtree admin whose +// admins: entry lives in /.zddc CAN edit that file — they own +// the directory and everything it grants. Footgun: they can also +// remove themselves from the admins list (recoverable: a super-admin +// always retains authority via the cascade from the root .zddc and +// can restore the grant). The prior strict-ancestor mode protected +// against peer-addition / delegator-removal but was always partial +// (deeper .zddc files were freely editable) and made the common +// case — "project creator wants to add a collaborator" — friction-y +// enough to be unusable. IsAdminForChain still accepts excludeLeaf +// for any caller that wants strict mode; the default path doesn't +// fire it. func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path, action string) (bool, error) { - excludeLeaf := action == ActionAdmin isAdmin := p.Elevated && p.Email != "" && - zddc.IsAdminForChain(chain, p.Email, excludeLeaf) + zddc.IsAdminForChain(chain, p.Email, false) in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)} in.User.Email = p.Email in.User.IsActiveAdmin = isAdmin