diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index aa2b96e..c0d70e3 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -758,11 +758,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { // unfenced so ancestor grants still cascade through. if email != "" { if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) { + roles := zddc.AutoOwnRolesAt(cfg.Root, abs) var werr error if zddc.AutoOwnFencedAt(cfg.Root, abs) { - werr = zddc.WriteAutoOwnZddcFenced(abs, email) + werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles) } else { - werr = zddc.WriteAutoOwnZddc(abs, email) + werr = zddc.WriteAutoOwnZddc(abs, email, roles) } if werr != nil { slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr) diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go index f0e20f1..6509553 100644 --- a/zddc/internal/handler/ssrhandler.go +++ b/zddc/internal/handler/ssrhandler.go @@ -145,11 +145,12 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit // auto_own in defaults.zddc.yaml, so the unfenced creator grant // fires here exactly as it would for a manual mkdir. if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) { + roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs) var werr error if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) { - werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email) + werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles) } else { - werr = zddc.WriteAutoOwnZddc(partyAbs, email) + werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles) } if werr != nil { slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr) diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 936b061..844e9c8 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -34,22 +34,53 @@ acl: # the reset are then excluded. # # document_controller — the people who file into -# archive//received/ and issued/ (WORM zones). They get -# read+write-once-create there (via the worm: lists below) and -# read/write elsewhere in a project, plus subtree-admin of the -# per-party working/ + staging/ + reviewing/ so they can stand up -# and manage drafting/transmittal/review folders. They are NOT -# subtree-admin of archive//, so the WORM constraint still -# binds them in received/issued. Plan-Review approval is part of -# this role by design — there is no separate `approver` role; -# two-person sign-off, when needed, is expressed via per-folder -# `.zddc` overrides rather than baked-in roles. +# archive//received/ and issued/ (WORM zones). They get: +# - rwcda at every archive// via the role grant written +# into each party's auto-own .zddc (auto_own_roles below). +# Cascade carries rwcda down to descendants by default. +# - read+write-once-create at received/issued via the worm: +# lists (the WORM mask strips w/d/a even though the role +# grant supplies rwcda at the party level above). +# - rwcd explicit at incoming/ and staging/ (the QC and +# transmittal-out workflows need `d` to move files between +# slots; the explicit grants shadow the inherited rwcda +# to make the intent visible). +# - rwc at archive/ so they can create party subfolders. +# +# NOT a subtree-admin anywhere. There is no `admins:` entry for +# the role — DCs cannot bypass WORM (only worm-create via the +# list) and cannot reach inside fenced working homes. Admin +# elevation is reserved for the root admins: list (the human +# escape hatch for mis-filed documents or recovery). +# +# Plan-Review approval is part of this role by design — there is +# no separate `approver` role; two-person sign-off, when needed, +# is expressed via per-folder `.zddc` overrides rather than +# baked-in roles. # # project_team — everyone working on a project. Read-only across -# the project. Their own archive//working// home and -# anything they create under incoming/ get a creator-owned auto- -# own .zddc (rwcda) which wins via deepest-match, so "read-only -# except what I own" falls out of the cascade with no special rule. +# the project by default, with a one-way ratchet through the +# in-flight slots: +# +# working/ cr — create + read; the auto_own_fenced child +# gives the creator rwcda in their own home, +# fenced from siblings +# staging/ cr — drop + read, no modify (after drop, the +# doc-controller is the only one who can +# change it) +# reviewing/ cr — create + read; auto_own (unfenced) gives +# creator rwcda in their iteration folder, +# siblings see it via project-level :r +# received/ r — WORM zone; only document_controller can +# file (and even they need elevation to edit) +# issued/ r — WORM zone; published, immutable +# incoming/ r — counterparty's drop zone (project_team +# observers it, doc_controller QCs it) +# +# "Each handoff drops the role's modify rights for the previous +# slot." That's the model — project_team works freely in +# working/, commits to staging/, and from there the doc- +# controller takes over. # # observer — pure read-only across the project. Like project_team # but with no auto-own home: an observer who somehow created a @@ -197,19 +228,31 @@ paths: paths: # Second segment under archive/ is the party name. "*": - # When the doc controller creates a party folder, an - # auto-own .zddc grants them rwcda there (UNFENCED — so - # the project-level project_team:r still cascades through - # to received/issued). That lets them set up the - # counterparty's own .zddc afterward. + # When the doc controller creates a party folder, the + # auto-own .zddc grants: + # - the creator's email rwcda (the standard auto_own + # mechanism) + # - the document_controller role rwcda (auto_own_roles + # below) so any DC in the role has full authority at + # every party, not just the parties they personally + # mkdir'd + # + # UNFENCED — so the project-level project_team:r still + # cascades through to received/issued/incoming. That + # lets the DC who created the party set up the counter- + # party's own .zddc afterward (e.g. granting them cr at + # incoming/). + # + # No `admins:` here by design. The DC role gets full + # authority via the role grant in the auto-own .zddc, not + # via subtree-admin status — so WORM masks at + # received/issued still bind them (they file write-once + # via the worm: list), and per-user fenced homes under + # working/ stay private to their creators. Admin + # elevation is reserved for the root admins list (the + # actual sudo-style escape hatch). auto_own: true - # Doc controller is subtree-admin of this party folder — - # full manage authority over the in-flight lifecycle - # slots (working/staging/reviewing) declared below. The - # WORM constraint on received/issued is enforced by the - # cascade's worm: lists, not by admin grants, so they - # still file write-once into those slots. - admins: [document_controller] + auto_own_roles: [document_controller] # SSR record: the party folder's ssr.yaml carries this # party's vendor / contract / status data. Scoped by # filename pattern so the lock on `kind` only applies to @@ -324,9 +367,39 @@ paths: # level /{working,staging,reviewing} virtuals # (declared above) are folder-nav views over these # canonical per-party slots. + # ── In-flight ratchet ─────────────────────────────── + # + # The lifecycle slots form a one-way handoff: + # + # working/ → staging/ → issued/ (WORM) + # (full) (cr) (worm cr) + # + # At each step the previous role's modify rights drop: + # project_team iterates freely in working/; when they + # promote to staging/ they can't change it without doc- + # controller help; when DC publishes to issued/ even + # they can't change it without elevation. Each ACL + # grant below is the verb-set the ROLE keeps at that + # step; auto_own + auto_own_fenced sub-folder grants + # layer per-creator ownership on top of these. working: default_tool: browse available_tools: [browse, classifier] + # Project_team gets read + create here so they can + # mkdir their own home folder (and any shared sub- + # folders). The auto_own_fenced declaration at the + # `*` child below makes the new folder a private home + # with rwcda for the creator (fenced from ancestors, + # so collaborators only join after the owner edits + # the home's .zddc to grant them access). + # + # `cr` instead of just `c` so an existing file at + # working/ root stays readable to all team members + # (cascade is per-level deepest-match — a single `c` + # would shadow the project-level `r`). + acl: + permissions: + project_team: cr # working/ auto-owns the first creator + the per-user # homes below. auto_own: true @@ -345,6 +418,29 @@ paths: staging: default_tool: transmittal available_tools: [transmittal, classifier] + # The ratchet step from working/. project_team gets + # `cr` — they can drop files (PUT new files at + # staging/) and read what's there, but cannot edit or + # delete after the drop. Once a file is in staging it + # belongs to the doc-controller workflow; the team + # member needs to ask DC to change it. + # + # Convention: project_team drops FILES at staging/, + # not sub-folders. A sub-folder mkdir'd by project_ + # team would trigger auto_own and grant them rwcda + # inside their own sub-folder (auto_own is path-keyed, + # not role-keyed — it fires for any creator). The + # auto_own here is preserved for DC's per-transmittal + # mkdir flow; project_team can keep to file drops to + # honour the "can't alter after" intent. + # + # DC gets rwcd explicitly — the staging-to-issued + # transfer needs `d` (cut, not copy) to move files + # out. Mirrors the incoming/ pattern at line 286-288. + acl: + permissions: + project_team: cr + document_controller: rwcd auto_own: true drop_target: true reviewing: @@ -359,5 +455,15 @@ paths: # from the party-level admins:) so the doc # controller can author per-folder .zddc files # (originator ACL, planned_date). + # + # project_team gets `cr` so the originating team can + # create review-iteration folders alongside the + # Plan-Review-scaffolded ones. auto_own (unfenced + # here, unlike working/) gives the creator rwcda + # inside; siblings see the iteration via the project- + # level project_team:r cascade. + acl: + permissions: + project_team: cr auto_own: true drop_target: true diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index df730f5..55ef11f 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -130,6 +130,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil absPath string autoOwn bool fenced bool + roles []string } var freshlyCreated []created @@ -224,10 +225,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil _ = i autoOwn := AutoOwnAt(fsRoot, pathSoFar) fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar) + var roles []string + if autoOwn { + roles = AutoOwnRolesAt(fsRoot, pathSoFar) + } freshlyCreated = append(freshlyCreated, created{ absPath: pathSoFar, autoOwn: autoOwn, fenced: fenced, + roles: roles, }) } @@ -235,7 +241,10 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // created. Skip if no principal email is available (anonymous or // system writes). The fenced variant is used at per-user home // folders under working/ — private by default; owner can later - // edit the .zddc to add collaborators. + // edit the .zddc to add collaborators. Role grants (from the + // cascade's auto_own_roles list) are written alongside the + // creator email so role-level peer authority survives without + // needing a subtree-admin grant. if principalEmail != "" { for _, c := range freshlyCreated { if !c.autoOwn { @@ -243,9 +252,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil } var werr error if c.fenced { - werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail) + werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles) } else { - werr = WriteAutoOwnZddc(c.absPath, principalEmail) + werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles) } if werr != nil { return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr) diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index c20edd1..e25b13f 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -231,6 +231,23 @@ type ZddcFile struct { // admin grants still apply. AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"` + // AutoOwnRoles augments AutoOwn with role-level grants: when set, + // the auto-own .zddc written at a new child directory grants each + // listed role `rwcda` ALONGSIDE the creator email. Lets the schema + // express "the creator owns it AND any member of these roles has + // full authority" without resorting to a separate admins: list + // (which would be subtree-admin and bypass WORM / fences via + // elevation — too strong for typical workflows). + // + // Example: archive// sets `auto_own_roles: [document_controller]` + // so any DC has rwcda at every party folder a peer created, not + // just at parties they personally mkdir'd. + // + // Grants are written as plain permissions in the new .zddc — they + // have no special semantic beyond what `rwcda` already means in + // the cascade. A fence (auto_own_fenced) still binds them. + AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,omitempty"` + // Virtual marks a directory as never-materialise-on-disk. The // server treats requests under such a path as virtual routes // rather than triggering EnsureCanonicalAncestors. The reviewing diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 222ca75..cd1083c 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -75,6 +75,23 @@ func AutoOwnAt(fsRoot, dirPath string) bool { return false } +// AutoOwnRolesAt returns the role names that should be granted rwcda +// in the auto-own .zddc at this dir (alongside the creator's email). +// Leaf-only, same semantic as AutoOwnAt / AutoOwnFencedAt. Empty/nil +// when the cascade declares no role grants — the legacy creator-only +// behavior. Caller passes the result to WriteAutoOwnZddc / Fenced. +func AutoOwnRolesAt(fsRoot, dirPath string) []string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return nil + } + leaf := leafLevel(chain) + if leaf.AutoOwnRoles != nil { + return leaf.AutoOwnRoles + } + return chain.Embedded.AutoOwnRoles +} + // AutoOwnFencedAt reports whether the auto-own .zddc at this dir // should be written with `inherit: false` (private to creator). // Leaf-only, same semantic as AutoOwnAt. diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index 55e26f4..a5fe197 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -11,19 +11,24 @@ import ( // IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt). // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting -// principalEmail rwcda and recording it in CreatedBy. Used by the file -// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed -// ownership when a new auto-own folder is materialised. +// principalEmail rwcda and recording it in CreatedBy. Each role name +// in roles also receives rwcda — gives the schema a way to declare +// "this folder is creator-owned AND any member of these roles has full +// authority" without using subtree-admin (which would bypass WORM / +// fences via elevation). Used by the file API's mkdir post-hook (and +// by EnsureCanonicalAncestors) to seed ownership when a new auto-own +// folder is materialised. Pass nil/empty roles for the legacy +// creator-only behavior. // -// The grant is identical to what an operator would write by hand — -// direct email pattern, "rwcda" verb set — so the creator can later -// edit the file normally to add collaborators. +// The grants are identical to what an operator would write by hand — +// direct email pattern + bare role names, "rwcda" verb set — so the +// creator can later edit the file normally to narrow or extend them. // // Atomic: marshals via the same yaml encoder ParseFile reads // (round-trip guaranteed) and writes via zddc.WriteFile (which // performs an atomic temp-write + rename via zddc.WriteAtomic). -func WriteAutoOwnZddc(dir, principalEmail string) error { - return writeAutoOwn(dir, principalEmail, false) +func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error { + return writeAutoOwn(dir, principalEmail, false, roles) } // WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally @@ -34,14 +39,26 @@ func WriteAutoOwnZddc(dir, principalEmail string) error { // Without the fence, an ancestor `*: r` (e.g. a project-root grant for // authenticated users) would let any user read every other user's // working subfolder via cascade — defeating the per-user sandbox. -func WriteAutoOwnZddcFenced(dir, principalEmail string) error { - return writeAutoOwn(dir, principalEmail, true) +// +// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda +// alongside the creator, and like the creator grant they're INSIDE +// the fence (only resolvable if the role is defined at this level or +// in chain.Embedded, since ancestor role definitions are hidden by +// inherit:false). Typically callers using the fenced variant pass nil +// roles — per-user homes don't need peer authority. +func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error { + return writeAutoOwn(dir, principalEmail, true, roles) } -func writeAutoOwn(dir, principalEmail string, fenced bool) error { - rules := ACLRules{ - Permissions: map[string]string{principalEmail: "rwcda"}, +func writeAutoOwn(dir, principalEmail string, fenced bool, roles []string) error { + perms := map[string]string{principalEmail: "rwcda"} + for _, role := range roles { + if role == "" || role == principalEmail { + continue // skip empty / collision with the creator entry + } + perms[role] = "rwcda" } + rules := ACLRules{Permissions: perms} if fenced { f := false rules.Inherit = &f diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index 9ad827f..50a11cf 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -6,29 +6,51 @@ import ( "testing" ) -// TestStandardRoles_DocControllerScopedCreate — with document_controller -// populated at the on-disk root, the role gets: -// - rw at the project level (read + overwrite-existing), but NOT c -// (so it can't make arbitrary folders) +// 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) -// - subtree-admin at archive// (full create + manage; lifecycle -// slots under the party inherit the admin grant) -// - inside received/issued (WORM): masked to r + worm-restored c -// -// Layout reshape: working/staging/reviewing moved from project root -// into archive//, so the subtree-admin scope likewise moved -// from project-level "working/staging/" to the per-party folder. +// - 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() - // Deployment populates the standard roles. Roles UNION with the - // embedded (empty) definitions, so this is the effective member set. + // Note: project_team's wildcard would normally also match dc@, + // which would shadow the role's rwcda at working/ (where the slot + // explicitly grants project_team:cr). Real deployments keep + // document_controller and project_team disjoint; the test fixture + // mirrors that. writeZddc(t, root, `roles: document_controller: members: ["dc@example.com"] project_team: - members: ["*@example.com"] + members: ["alice@example.com"] `) + // Simulate the auto-own .zddc the file API writes when DC mkdir's + // archive/Acme/. Carries the creator email + the document_controller + // role per the embedded defaults' auto_own_roles entry. + partyDir := filepath.Join(root, "Proj", "archive", "Acme") + if err := os.MkdirAll(partyDir, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, partyDir, `acl: + permissions: + "dc@example.com": rwcda + document_controller: rwcda +created_by: dc@example.com +`) + resetCache() + dc := "dc@example.com" mustVerbs := func(dir string, want string) { @@ -37,7 +59,6 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { if err != nil { t.Fatalf("EffectivePolicy(%q): %v", dir, err) } - // Mirror InternalDecider.Allow's WORM-aware composition. var got VerbSet if g, inWorm := WormZoneGrant(chain, dc); inWorm { got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC) @@ -55,36 +76,92 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw") // archive/: rwc (can create party folders). mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc") - // incoming/: rwcd — the QC + transfer-out workflow needs delete. - mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd") - // received/ (WORM): rw masked to r, plus worm-restored c → "rc". - mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc") - mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc") + // At the party folder itself: rwcda via the auto-own role grant. + mustVerbs(partyDir, "rwcda") + // Lifecycle slots inside the party folder inherit rwcda from the + // party-level role grant where no slot-local grant overrides. + mustVerbs(filepath.Join(partyDir, "working"), "rwcda") + mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda") + // incoming/ and staging/ have explicit document_controller: rwcd + // — leaf-wins shadows the rwcda inherited from /. + mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd") + mustVerbs(filepath.Join(partyDir, "staging"), "rwcd") + // received/ (WORM): inherited rwcda masked to r + worm-restored c. + mustVerbs(filepath.Join(partyDir, "received"), "rc") + mustVerbs(filepath.Join(partyDir, "issued"), "rc") - // Subtree-admin at archive// (the embedded cascade - // declares admins: [document_controller] on the party "*" entry, - // so working/staging/reviewing inside the party inherit it). - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should be subtree-admin of archive//") + // NOT subtree-admin anywhere — even when notionally elevated, + // the role carries no admin: grant. + for _, p := range []string{ + filepath.Join(root, "Proj"), + filepath.Join(root, "Proj", "archive"), + partyDir, + filepath.Join(partyDir, "working"), + filepath.Join(partyDir, "staging"), + filepath.Join(partyDir, "reviewing"), + filepath.Join(partyDir, "received"), + filepath.Join(partyDir, "issued"), + } { + 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):]) + } } - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should be subtree-admin of archive//working/") + // And specifically — they CAN'T reach inside a fenced per-user + // working home. The fence isolates team-member workspaces from + // every other role (including DC) by design. + homeDir := filepath.Join(partyDir, "working", "alice@example.com") + if err := os.MkdirAll(homeDir, 0o755); err != nil { + t.Fatal(err) } - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should be subtree-admin of archive//staging/") + writeZddc(t, homeDir, `acl: + inherit: false + permissions: + "alice@example.com": rwcda +created_by: alice@example.com +`) + resetCache() + chain, _ := EffectivePolicy(root, homeDir) + if got := EffectiveVerbs(chain, dc); got != 0 { + t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String()) } - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should be subtree-admin of archive//reviewing/") +} + +// 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) } - // NOT subtree-admin of archive/ (so WORM still binds them at the - // received/issued slots below). - if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)") + 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()) } - // Subtree-admin reaches inside a fenced per-user working home - // under the party's working slot. - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home") + // 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()) } } @@ -141,6 +218,73 @@ created_by: alice@example.com } } +// 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) + } + } + + party := filepath.Join(root, "Proj", "archive", "Acme") + mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot + mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify + mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders + mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create + mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same + mustVerbs(filepath.Join(party, "incoming"), "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 defaults.zddc.yaml) 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", "archive", "Acme", "staging")) + 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 diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 925b3e8..a4e84a5 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -78,6 +78,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.AutoOwnFenced != nil { out.AutoOwnFenced = top.AutoOwnFenced } + // AutoOwnRoles: presence (non-nil) overrides; a deeper level + // declaring an empty list replaces (and explicitly suppresses) + // the ancestor's role list. This matches the leaf-wins semantic + // for the other path-tree contribution lists. + if top.AutoOwnRoles != nil { + out.AutoOwnRoles = top.AutoOwnRoles + } if top.DropTarget != nil { out.DropTarget = top.DropTarget }