From 54dff4dcd34723411b6098acb0492d530b583666 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 10:17:46 -0500 Subject: [PATCH] feat(zddc): standard roles (document_controller, project_team) + role union/reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "can roles reset as well as add?" — yes, both now. Role membership UNIONS across the cascade: - A deeper .zddc that defines an inherited role again with one extra member ADDS that member (was: deepest definition shadowed the ancestor's entirely). - New `reset: true` on a role definition breaks the union — that level's members are authoritative, ancestor definitions above are excluded; descendants below still union on top. Use it to give a project its own team independent of a deployment-wide default. - lookupRoleMembers / RoleMembers reworked: walk deep→shallow, union members, stop at the first reset:true; finally fold in chain.Embedded.Roles as the baseline so a role declared only in defaults.zddc.yaml is "defined" (and a deployment's on-disk redefinition unions on top). Admin checks are now role-aware: - IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use MatchesPrincipal instead of MatchesPattern, so `admins: [document_controller]` resolves to the role's members. The strict-ancestor scan resolves roles only up to level i, so a role defined at the deepest level (= dirPath) never confers self-edit rights. Two standard roles ship in defaults.zddc.yaml (empty members — a fresh deployment grants nothing until they're populated): document_controller — files into the WORM zones. Gets: - rw at the project level (read + overwrite-existing; 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, including taking over a fenced per-user home) — scoped HERE, not at the project root, so the WORM constraint still binds it in archive//received|issued - listed in worm: on received/ and issued/ → write-once-create survives the WORM mask project_team — read-only across the project. The per-user working home's fenced auto-own .zddc (rwcda for the creator) wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule. Inside received/issued their r is preserved (worm: doesn't strip read). archive// gains `auto_own: true` (UNFENCED) so whoever creates a party subtree (normally the doc controller) owns it and can set up that counterparty's .zddc afterward — without fencing, project_team:r still cascades through to received/issued. Tests: roles_test (union + reset), standardroles_test (the doc-controller scoped-create matrix + project-team read-only-except- owned), ensure_test updated for the new party-folder auto-own. fileapi_test's WORM doc-controller test already uses worm: [role]. All Go + 248 Playwright tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/admin.go | 18 +++- zddc/internal/zddc/defaults.zddc.yaml | 72 +++++++++++++- zddc/internal/zddc/ensure_test.go | 22 +++-- zddc/internal/zddc/file.go | 17 +++- zddc/internal/zddc/roles.go | 65 +++++++----- zddc/internal/zddc/roles_test.go | 55 +++++++++-- zddc/internal/zddc/standardroles_test.go | 120 +++++++++++++++++++++++ zddc/internal/zddc/walker.go | 22 ++++- 9 files changed, 343 insertions(+), 50 deletions(-) create mode 100644 zddc/internal/zddc/standardroles_test.go diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 7658021..2bf3c40 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-12 14:40:09 · 918f330-dirty + v0.0.17-alpha · 2026-05-12 15:16:28 · 2de2fdf-dirty
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