diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go
index 29976b8..48f00c9 100644
--- a/zddc/internal/zddc/admin.go
+++ b/zddc/internal/zddc/admin.go
@@ -34,6 +34,11 @@ func IsAdmin(fsRoot, email string) bool {
// dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath.
//
+// Admins entries may be email-glob patterns OR role references (a bare
+// role name, or @role:) — resolved the same way acl.permissions
+// keys are, so `admins: [document_controller]` works once a deployment
+// populates that role.
+//
// This is the read-side check — "can email *see* admin tools for this
// subtree?". For write authority over a specific .zddc file, use
// CanEditZddc, which adds the strict-ancestor rule that prevents
@@ -46,9 +51,9 @@ func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
if err != nil {
return false
}
- for _, level := range chain.Levels {
- for _, pattern := range level.Admins {
- if MatchesPattern(pattern, email) {
+ for i, level := range chain.Levels {
+ for _, principal := range level.Admins {
+ if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
return true
}
}
@@ -93,9 +98,12 @@ func CanEditZddc(fsRoot, dirPath, email string) bool {
// Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath.
// EffectivePolicy returns levels ordered root (index 0) → leaf (last).
+ // Admins entries may be email globs or role references (resolved
+ // against the chain up to level i — so a role defined at the
+ // deepest level, which is dirPath, never confers self-edit rights).
for i := 0; i < len(chain.Levels)-1; i++ {
- for _, pattern := range chain.Levels[i].Admins {
- if MatchesPattern(pattern, email) {
+ for _, principal := range chain.Levels[i].Admins {
+ if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
return true
}
}
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml
index d626b34..177887d 100644
--- a/zddc/internal/zddc/defaults.zddc.yaml
+++ b/zddc/internal/zddc/defaults.zddc.yaml
@@ -20,6 +20,38 @@ title: "ZDDC"
acl:
permissions: {}
+# ── Standard roles ─────────────────────────────────────────────────────────
+#
+# Two roles ship empty (no members) — a fresh deployment grants
+# nothing until an operator populates them. They're referenced by the
+# project-scoped grants in paths: below.
+#
+# Role membership UNIONS across the cascade: an on-disk .zddc that
+# defines `project_team` again with one extra member ADDS that member
+# to the inherited role. To start fresh at a subtree (e.g. a project
+# wanting its own team independent of a deployment-wide default), use
+# `reset: true` on the role at that level — ancestor definitions above
+# 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 working/ and
+# staging/ so they can stand up new top-level folders and manage
+# user/staging subtrees. They are NOT subtree-admin of archive/,
+# so the WORM constraint still binds them in received/issued.
+#
+# project_team — everyone working on a project. Read-only across
+# the project. Their own 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.
+roles:
+ document_controller:
+ members: []
+ project_team:
+ members: []
+
# Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical
# folder below adds its own context-specific tools (mdedit in
@@ -48,12 +80,36 @@ available_tools: [archive, browse, landing]
paths:
# First segment under root is the project name; "*" matches any.
"*":
+ # Project-scoped baseline ACL. project_team gets read across the
+ # project; document_controller gets read + overwrite-existing
+ # (so people can ask them to fix a stuck file). Neither gets
+ # `c` (create) at this level — that's granted only at the
+ # specific spots below (archive/, working/, staging/), so the
+ # doc controller can't make arbitrary folders. Grants here cap
+ # at deeper levels per deepest-match-wins, except where a deeper
+ # .zddc restates a fuller grant for the same principal.
+ acl:
+ permissions:
+ project_team: r
+ document_controller: rw
paths:
archive:
default_tool: archive
+ # The doc controller can create party subfolders here
+ # (archive//). Restate the full grant — deepest-match
+ # is per-principal replacement, so we re-list rw + add c.
+ acl:
+ permissions:
+ document_controller: rwc
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.
+ auto_own: true
paths:
mdl:
default_tool: tables
@@ -94,10 +150,13 @@ paths:
# cascade, so a deeper .zddc adds more controllers.
received:
default_tool: archive
- worm: []
+ # document_controller may file write-once into the
+ # WORM zone. Their project-level rw is masked here
+ # to r; worm: restores write-once-create.
+ worm: [document_controller]
issued:
default_tool: archive
- worm: []
+ worm: [document_controller]
working:
default_tool: mdedit
available_tools: [mdedit, classifier]
@@ -105,6 +164,12 @@ paths:
# below.
auto_own: true
drop_target: true
+ # Doc controller is subtree-admin of working/ — full create
+ # + manage, including taking over a fenced per-user home if a
+ # user leaves. (Scoped here, not at the project root, so the
+ # WORM constraint in archive//received|issued still
+ # binds them.)
+ admins: [document_controller]
paths:
"*": # per-user home dir
default_tool: mdedit
@@ -121,6 +186,9 @@ paths:
available_tools: [transmittal, classifier]
auto_own: true
drop_target: true
+ # Doc controller is subtree-admin of staging/ too — same
+ # rationale as working/.
+ admins: [document_controller]
reviewing:
default_tool: mdedit
available_tools: [mdedit]
diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go
index 3104868..84142d3 100644
--- a/zddc/internal/zddc/ensure_test.go
+++ b/zddc/internal/zddc/ensure_test.go
@@ -112,7 +112,8 @@ func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
t.Fatalf("ensure: %v", err)
}
- // archive/ created (no auto-own).
+ // archive/ created (no auto-own — archive/ itself is a plain
+ // container; the cascade declares no auto_own there).
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
t.Errorf("archive/ not created: %v", err)
}
@@ -120,12 +121,21 @@ func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
}
- // archive/ACME/ created (no auto-own — it's a party folder, not canonical).
- if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME")); err != nil {
- t.Errorf("ACME/ not created: %v", err)
+ // archive/ACME/ created WITH auto-own (the cascade declares
+ // auto_own on the party-folder level so whoever creates a party
+ // subtree owns it — used by the document controller to set up a
+ // new counterparty's .zddc). Unfenced, so ancestor grants still
+ // reach inside (project_team:r through to received/issued).
+ partyZ := filepath.Join(root, "Proj", "archive", "ACME", ".zddc")
+ pdata, err := os.ReadFile(partyZ)
+ if err != nil {
+ t.Fatalf("auto-own .zddc at ACME/ not written: %v", err)
}
- if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", ".zddc")); !os.IsNotExist(err) {
- t.Errorf("ACME/ should not have auto-own .zddc; got err=%v", err)
+ if !strings.Contains(string(pdata), "rep@acme.com: rwcda") {
+ t.Errorf("ACME/ auto-own missing rep grant: %s", pdata)
+ }
+ if strings.Contains(string(pdata), "inherit: false") {
+ t.Errorf("ACME/ auto-own should be UNFENCED; got: %s", pdata)
}
// archive/ACME/incoming/ created WITH auto-own.
diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go
index 79ad466..85cb61a 100644
--- a/zddc/internal/zddc/file.go
+++ b/zddc/internal/zddc/file.go
@@ -73,10 +73,23 @@ func (r ACLRules) InheritsAncestors() bool {
// Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see
// MatchesPattern). A role defined at level L is in scope at L and all
-// descendants; a level closer to the leaf may shadow an ancestor's
-// role definition by redefining the same name.
+// descendants.
+//
+// Role membership UNIONS across the cascade: if the same role name is
+// defined at multiple levels, the effective member set is the union
+// of all those definitions. So a deeper .zddc that lists one extra
+// member ADDS it to the inherited role rather than replacing the
+// whole list.
+//
+// Reset breaks the union: when true, this level's definition is
+// authoritative for the role — ancestor (shallower) definitions are
+// ignored. Descendants that also define the role (without reset)
+// still union on top. Use reset to start a role's membership fresh at
+// a subtree boundary (e.g. a project that wants its own project_team
+// independent of the deployment-wide default).
type Role struct {
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
+ Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
diff --git a/zddc/internal/zddc/roles.go b/zddc/internal/zddc/roles.go
index c13f39b..6dca784 100644
--- a/zddc/internal/zddc/roles.go
+++ b/zddc/internal/zddc/roles.go
@@ -99,30 +99,21 @@ func IsPrincipalRole(principal string) bool {
return !strings.Contains(principal, "@")
}
-// RoleMembers returns the member-pattern list for roleName as visible
-// at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and
-// returns the first definition found (closer-to-leaf wins). The lower
-// bound is determined by chain.VisibleStart(levelIdx, mode): in
-// delegated mode, an inherit:false fence at-or-below levelIdx hides
-// any role definitions in levels above it; in strict mode the full
-// chain is visible. Returns nil if no level in the visible chain
-// defines the role.
+// RoleMembers returns the effective member-pattern list for roleName
+// as visible at chain.Levels[levelIdx] — the UNION of every level's
+// definition in the visible chain, with a role.Reset=true level
+// stopping the walk (its members plus anything deeper; ancestors
+// above the reset excluded). The visible-chain lower bound is
+// chain.VisibleStart(levelIdx, mode): in delegated mode, an
+// inherit:false fence at-or-below levelIdx hides definitions above
+// it; in strict mode the full chain is visible. Returns nil if no
+// level in the visible chain defines the role.
//
// Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
- if levelIdx < 0 || levelIdx >= len(chain.Levels) {
- return nil
- }
- floor := chain.VisibleStart(levelIdx, mode)
- for i := levelIdx; i >= floor; i-- {
- role, ok := chain.Levels[i].Roles[roleName]
- if !ok {
- continue
- }
- return role.Members
- }
- return nil
+ members, _ := lookupRoleMembers(chain, levelIdx, roleName, mode)
+ return members
}
// MatchesPrincipal reports whether email satisfies the given Permissions
@@ -162,19 +153,49 @@ func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int,
// but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. The
// visible-chain bound is determined by chain.VisibleStart(levelIdx, mode).
+//
+// Members UNION across every level that defines the role. Walking
+// deep→shallow, a level with role.Reset=true stops the walk: its
+// members (plus anything deeper that already accumulated) are the
+// final set; ancestor definitions above the reset are excluded.
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false
}
floor := chain.VisibleStart(levelIdx, mode)
+ var members []string
+ seen := make(map[string]struct{})
+ defined := false
+ addAll := func(ms []string) {
+ for _, m := range ms {
+ if _, dup := seen[m]; dup {
+ continue
+ }
+ seen[m] = struct{}{}
+ members = append(members, m)
+ }
+ }
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
}
- return role.Members, true
+ defined = true
+ addAll(role.Members)
+ if role.Reset {
+ return members, true // authoritative; ignore ancestors + embedded
+ }
}
- return nil, false
+ // The embedded defaults sit below chain.Levels[0] in the cascade.
+ // Fold its role definitions in as the baseline (so a role declared
+ // only in defaults.zddc.yaml is "defined", and a deployment's
+ // on-disk redefinition unions on top). Skipped above only if a
+ // reset:true level already returned.
+ if role, ok := chain.Embedded.Roles[roleName]; ok {
+ defined = true
+ addAll(role.Members)
+ }
+ return members, defined
}
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
diff --git a/zddc/internal/zddc/roles_test.go b/zddc/internal/zddc/roles_test.go
index c9ec577..7fb61b7 100644
--- a/zddc/internal/zddc/roles_test.go
+++ b/zddc/internal/zddc/roles_test.go
@@ -64,14 +64,14 @@ func TestIsPrincipalRole(t *testing.T) {
}
}
-func TestRoleMembersClosestLeafWins(t *testing.T) {
+func TestRoleMembersUnionAcrossCascade(t *testing.T) {
chain := PolicyChain{
Levels: []ZddcFile{
- // root: role defined with one set of members
+ // root: role defined with one member
{Roles: map[string]Role{
"editors": {Members: []string{"alice@example.com"}},
}},
- // child: shadows with a different set
+ // child: ADDS a member (union, not shadow)
{Roles: map[string]Role{
"editors": {Members: []string{"bob@example.com"}},
}},
@@ -79,13 +79,54 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
HasAnyFile: true,
}
got := RoleMembers(chain, 1, "editors", ModeDelegated)
- if len(got) != 1 || got[0] != "bob@example.com" {
- t.Errorf("leaf shadow failed: %v", got)
+ if len(got) != 2 {
+ t.Fatalf("union: got %v, want both alice + bob", got)
}
- // At root level, only the root definition is visible.
+ has := func(s string) bool {
+ for _, g := range got {
+ if g == s {
+ return true
+ }
+ }
+ return false
+ }
+ if !has("alice@example.com") || !has("bob@example.com") {
+ t.Errorf("union: got %v, want alice + bob", got)
+ }
+ // At root level, only the root definition is in the visible chain.
got = RoleMembers(chain, 0, "editors", ModeDelegated)
if len(got) != 1 || got[0] != "alice@example.com" {
- t.Errorf("root visibility failed: %v", got)
+ t.Errorf("root visibility: got %v, want [alice]", got)
+ }
+}
+
+func TestRoleMembersResetBreaksUnion(t *testing.T) {
+ chain := PolicyChain{
+ Levels: []ZddcFile{
+ // root
+ {Roles: map[string]Role{
+ "editors": {Members: []string{"alice@example.com"}},
+ }},
+ // mid: reset — ancestors above this are excluded for editors
+ {Roles: map[string]Role{
+ "editors": {Members: []string{"carol@example.com"}, Reset: true},
+ }},
+ // leaf: still unions on top of the reset level
+ {Roles: map[string]Role{
+ "editors": {Members: []string{"dave@example.com"}},
+ }},
+ },
+ HasAnyFile: true,
+ }
+ got := RoleMembers(chain, 2, "editors", ModeDelegated)
+ // Expect carol (reset level) + dave (leaf), NOT alice (excluded by reset).
+ if len(got) != 2 {
+ t.Fatalf("reset: got %v, want carol + dave only", got)
+ }
+ for _, g := range got {
+ if g == "alice@example.com" {
+ t.Errorf("reset should have excluded alice; got %v", got)
+ }
}
}
diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go
new file mode 100644
index 0000000..60ac1eb
--- /dev/null
+++ b/zddc/internal/zddc/standardroles_test.go
@@ -0,0 +1,120 @@
+package zddc
+
+import (
+ "os"
+ "path/filepath"
+ "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)
+// - rwc at archive/ (can create party subfolders)
+// - subtree-admin at working/ and staging/ (full create + manage)
+// - inside received/issued (WORM): masked to r + worm-restored c
+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.
+ writeZddc(t, root, `roles:
+ document_controller:
+ members: ["dc@example.com"]
+ project_team:
+ members: ["*@example.com"]
+`)
+ dc := "dc@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.
+ var got VerbSet
+ if g, inWorm := WormZoneGrant(chain, dc, ModeDelegated); inWorm {
+ got = (EffectiveVerbs(chain, dc, ModeDelegated) & VerbR) | (g & VerbsRC)
+ } else {
+ got = EffectiveVerbs(chain, dc, ModeDelegated)
+ }
+ 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(filepath.Join(root, "Proj"), "rw")
+ // A random subfolder under the project inherits rw (no c).
+ mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
+ // archive/: rwc (can create party folders).
+ mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
+ // 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")
+
+ // Subtree-admin at working/ and staging/ (via admins: [document_controller]
+ // in the embedded cascade — role-aware now).
+ if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), dc) {
+ t.Errorf("doc controller should be subtree-admin of working/")
+ }
+ if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), dc) {
+ t.Errorf("doc controller should be subtree-admin of staging/")
+ }
+ // NOT subtree-admin of archive/ (so WORM still binds them there).
+ if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), dc) {
+ t.Errorf("doc controller should NOT be subtree-admin of archive/")
+ }
+ // Subtree-admin reaches inside a fenced per-user working home.
+ if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), dc) {
+ t.Errorf("doc controller (subtree-admin of working/) should reach inside a fenced user home")
+ }
+}
+
+// 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
+ // working/alice@example.com/ (fenced via acl.inherit:false,
+ // creator-owned).
+ homeDir := filepath.Join(root, "Proj", "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, ModeDelegated); 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, ModeDelegated); 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, ModeDelegated); got.String() != "r" {
+ t.Errorf("alice in archive/Acme = %q, want r", got.String())
+ }
+}
diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go
index 56765e6..8c9f917 100644
--- a/zddc/internal/zddc/walker.go
+++ b/zddc/internal/zddc/walker.go
@@ -102,12 +102,24 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display)
- // Roles: shallow replace if top sets any. Roles are subtree-
- // scoped principal groups; layered merge of named lists would be
- // surprising — operators expecting a clean replacement at the
- // override level is the conventional pattern.
+ // Roles: per-name merge (top wins on name clash). This combines
+ // the on-disk .zddc at a level with any virtual contributions
+ // from ancestor paths: at the same level. Cross-LEVEL role
+ // membership union (and the reset flag) is handled at lookup
+ // time by lookupRoleMembers, not here.
if len(top.Roles) > 0 {
- out.Roles = top.Roles
+ if out.Roles == nil {
+ out.Roles = make(map[string]Role, len(top.Roles))
+ } else {
+ merged := make(map[string]Role, len(out.Roles)+len(top.Roles))
+ for k, v := range out.Roles {
+ merged[k] = v
+ }
+ out.Roles = merged
+ }
+ for k, v := range top.Roles {
+ out.Roles[k] = v
+ }
}
// Paths: top entirely replaces base if set. Recursive descent of