package zddc import ( "os" "path/filepath" "testing" ) // TestStandardRoles_DocControllerScopedCreate — DC authority comes // PURELY from the cascade now (no subtree-admin / admins: list). The // model: // - rw at the project level (read + overwrite-existing, no `c`) // - rwc at archive/ (can create party subfolders) // - When DC mkdirs archive//, ensure.go writes an auto-own // .zddc granting both the creator email AND the document_controller // role rwcda there (via auto_own_roles in defaults). This test // simulates that .zddc directly so the cascade behaviour can be // asserted in isolation. // - From the party's auto-own .zddc, the role rwcda cascades down to // descendants by default; explicit slot grants (rwcd at incoming/ // and staging/) shadow it where the workflow needs `d`. // - At received/issued (WORM): the WORM mask strips w/d/a from the // inherited rwcda; the worm: list restores c → effective cr. // - NOT subtree-admin anywhere — no admins: entries for the role. func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { resetCache() root := t.TempDir() // DC authority comes PURELY from the cascade peer grants in // internal/zddc/defaults/ — no auto-own / admins: list. DCs are typically // in project_team too (the *@example.com wildcard); the defaults // restate document_controller at each peer so the within-level union // gives the DC the higher grant. writeZddc(t, root, `roles: document_controller: members: ["dc@example.com"] project_team: members: ["*@example.com"] `) resetCache() dc := "dc@example.com" j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) } mustVerbs := func(dir string, want string) { t.Helper() chain, err := EffectivePolicy(root, dir) if err != nil { t.Fatalf("EffectivePolicy(%q): %v", dir, err) } var got VerbSet if g, inWorm := WormZoneGrant(chain, dc); inWorm { got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC) } else { got = EffectiveVerbs(chain, dc) } if got.String() != want { t.Errorf("doc controller verbs at %s = %q, want %q", dir[len(root):], got.String(), want) } } // Project level: rw (no c). mustVerbs(j(), "rw") mustVerbs(j("random-folder"), "rw") // archive subtree is WORM: read + worm-create only (w/d/a stripped). mustVerbs(j("archive"), "rc") mustVerbs(j("archive", "Acme"), "rc") mustVerbs(j("archive", "Acme", "received"), "rc") mustVerbs(j("archive", "Acme", "issued"), "rc") // Workspace peers: full authority via the peer-level DC grant. mustVerbs(j("working", "Acme"), "rwcda") mustVerbs(j("staging", "Acme"), "rwcda") mustVerbs(j("reviewing", "Acme"), "rwcda") mustVerbs(j("incoming", "Acme"), "rwcd") // Registers. mustVerbs(j("mdl", "Acme"), "rwcd") mustVerbs(j("rsk", "Acme"), "rwcd") mustVerbs(j("ssr"), "rwc") // NOT subtree-admin anywhere — no admins: grant for the role. for _, p := range []string{j(), j("archive"), j("working", "Acme"), j("ssr"), j("mdl", "Acme")} { if IsSubtreeAdmin(root, p, Principal{Email: dc, Elevated: true}) { t.Errorf("doc controller should NOT be subtree-admin of %s (no admins: list anywhere)", p[len(root):]) } } } // TestStandardRoles_DocControllerMultiDC — a second DC added to the // role gets the SAME rwcda at every party that any DC created, // because the auto-own .zddc grants the role (not just the creator's // email) via auto_own_roles in defaults. func TestStandardRoles_DocControllerMultiDC(t *testing.T) { resetCache() root := t.TempDir() writeZddc(t, root, `roles: document_controller: members: ["dc1@example.com", "dc2@example.com"] `) // dc1 created the party folder; the auto-own .zddc lists both // dc1 (creator email) and the document_controller role (from // auto_own_roles in defaults). partyDir := filepath.Join(root, "Proj", "archive", "Acme") if err := os.MkdirAll(partyDir, 0o755); err != nil { t.Fatal(err) } writeZddc(t, partyDir, `acl: permissions: "dc1@example.com": rwcda document_controller: rwcda created_by: dc1@example.com `) resetCache() chain, _ := EffectivePolicy(root, partyDir) // dc1 (creator) has rwcda directly. if got := EffectiveVerbs(chain, "dc1@example.com"); got.String() != "rwcda" { t.Errorf("dc1 (creator) at party = %q, want rwcda", got.String()) } // dc2 (non-creator) ALSO has rwcda via the role grant. This is // the whole point of auto_own_roles — peer DCs share authority // without admin status. if got := EffectiveVerbs(chain, "dc2@example.com"); got.String() != "rwcda" { t.Errorf("dc2 (peer) at party = %q, want rwcda (role grant from auto_own_roles)", got.String()) } } // TestStandardRoles_ProjectTeamReadOnlyExceptOwned — project_team gets // r across the project, but the per-user working home's auto-own .zddc // (rwcda for the creator) wins via deepest-match, so a team member has // full rights in their own home and read-only elsewhere. func TestStandardRoles_ProjectTeamReadOnlyExceptOwned(t *testing.T) { resetCache() root := t.TempDir() writeZddc(t, root, `roles: project_team: members: ["*@example.com"] `) // Simulate the auto-own .zddc the file API would write at // archive/Acme/working/alice@example.com/ (fenced via // acl.inherit:false, creator-owned). homeDir := filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com") if err := os.MkdirAll(homeDir, 0o755); err != nil { t.Fatal(err) } writeZddc(t, homeDir, `acl: inherit: false permissions: "alice@example.com": rwcda created_by: alice@example.com `) resetCache() alice := "alice@example.com" bob := "bob@example.com" // Alice (team member) inside her own home → rwcda. chain, _ := EffectivePolicy(root, homeDir) if got := EffectiveVerbs(chain, alice); got.String() != "rwcda" { t.Errorf("alice in own home = %q, want rwcda", got.String()) } // Bob (team member) inside Alice's fenced home → nothing (fence // blocks the project-level project_team:r; bob isn't named in the // fenced .zddc). if got := EffectiveVerbs(chain, bob); got != 0 { t.Errorf("bob in alice's fenced home = %q, want empty (fence blocks inherited grants)", got.String()) } // Alice elsewhere in the project (not her home, not WORM) → r. chain2, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme")) if got := EffectiveVerbs(chain2, alice); got.String() != "r" { t.Errorf("alice in archive/Acme = %q, want r", got.String()) } // Alice CANNOT write to incoming/ — that's the counterparty's drop // zone, QC'd by the document controller. project_team gets read only. chain3, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "incoming")) if got := EffectiveVerbs(chain3, alice); got.String() != "r" { t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String()) } } // TestStandardRoles_ProjectTeamInFlightRatchet — the one-way handoff // from working/ → staging/ → issued/ as the team member sees it: // full work in working (cr at the slot + rwcda inside the fenced // home), drop-only in staging (cr — no modify after the drop), drop // inside auto-own iteration folder in reviewing (cr at the slot, // rwcda inside the auto-owned sub-folder), read-only in received/ // issued (WORM zones) and incoming/ (counterparty drop zone). func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) { resetCache() root := t.TempDir() writeZddc(t, root, `roles: project_team: members: ["*@example.com"] `) alice := "alice@example.com" mustVerbs := func(dir string, want string) { t.Helper() chain, err := EffectivePolicy(root, dir) if err != nil { t.Fatalf("EffectivePolicy(%q): %v", dir, err) } var got VerbSet if g, inWorm := WormZoneGrant(chain, alice); inWorm { got = (EffectiveVerbs(chain, alice) & VerbR) | (g & VerbsRC) } else { got = EffectiveVerbs(chain, alice) } if got.String() != want { t.Errorf("project_team alice at %s = %q, want %q", dir[len(root):], got.String(), want) } } j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) } mustVerbs(j("working", "Acme"), "rc") // create + read at the workspace mustVerbs(j("staging", "Acme"), "rc") // drop + read, no modify mustVerbs(j("reviewing", "Acme"), "rc") // create iteration folders mustVerbs(j("archive", "Acme", "received"), "r") // WORM — read pass-through mustVerbs(j("archive", "Acme", "issued"), "r") // WORM — same mustVerbs(j("incoming", "Acme"), "r") // counterparty drop zone — read only } // TestStandardRoles_DocControllerStagingDelete — DC needs `d` at // staging/ to perform the staging-to-issued transfer (cut, not copy); // the explicit document_controller: rwcd grant supplies it. Mirrors // the incoming/ pattern (line 286-288 of internal/zddc/defaults/) where // the QC + transfer-out workflow needed the same. func TestStandardRoles_DocControllerStagingDelete(t *testing.T) { resetCache() root := t.TempDir() writeZddc(t, root, `roles: document_controller: members: ["dc@example.com"] `) dc := "dc@example.com" chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "staging", "Acme")) if err != nil { t.Fatalf("EffectivePolicy: %v", err) } got := EffectiveVerbs(chain, dc) for _, v := range []VerbSet{VerbR, VerbW, VerbC, VerbD} { if !got.Has(v) { t.Errorf("doc controller at staging/ missing verb %q in %q (need rwcd for transfer-to-issued)", v.String(), got.String()) } } } // TestStandardRoles_ObserverReadOnlyEverywhere — observer is the // project-wide read-only role for auditors / regulators / external // viewers. Unlike project_team, an observer must not contribute // content anywhere: no create at archive/, no create at working/, // no worm-create at received/issued, and not subtree-admin of // anything. Read passes through WORM zones (worm: lists strip w/d/a // but never r). func TestStandardRoles_ObserverReadOnlyEverywhere(t *testing.T) { resetCache() root := t.TempDir() writeZddc(t, root, `roles: observer: members: ["auditor@example.com"] `) obs := "auditor@example.com" mustVerbs := func(dir string, want string) { t.Helper() chain, err := EffectivePolicy(root, dir) if err != nil { t.Fatalf("EffectivePolicy(%q): %v", dir, err) } // Mirror InternalDecider.Allow's WORM-aware composition so the // assertion covers received/issued correctly. var got VerbSet if g, inWorm := WormZoneGrant(chain, obs); inWorm { got = (EffectiveVerbs(chain, obs) & VerbR) | (g & VerbsRC) } else { got = EffectiveVerbs(chain, obs) } if got.String() != want { t.Errorf("observer verbs at %s = %q, want %q", dir[len(root):], got.String(), want) } } // Project level: read-only. mustVerbs(filepath.Join(root, "Proj"), "r") // A random subfolder under the project still read-only. mustVerbs(filepath.Join(root, "Proj", "random-folder"), "r") // archive/ — read-only (no create at the party-folder level). mustVerbs(filepath.Join(root, "Proj", "archive"), "r") // incoming/ — read-only (no create even though incoming/ has // drop_target and auto_own; the cascade ACL still gates create). mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "r") // In-flight lifecycle slots — read-only. mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "working"), "r") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "staging"), "r") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), "r") // WORM zones — read passes through; no worm-create (observer is // not in the worm: list). mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "r") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "r") // Observer is not subtree-admin of anything in the project — even // when notionally elevated, the role carries no admin grant. if IsSubtreeAdmin(root, filepath.Join(root, "Proj"), Principal{Email: obs, Elevated: true}) { t.Errorf("observer should NOT be subtree-admin of the project root") } if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: obs, Elevated: true}) { t.Errorf("observer should NOT be subtree-admin of archive/") } if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: obs, Elevated: true}) { t.Errorf("observer should NOT be subtree-admin of archive//") } }