feat(zddc): standard roles (document_controller, project_team) + role union/reset

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/<party>/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/<party>/ 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 10:17:46 -05:00
parent 2de2fdf92c
commit 54dff4dcd3
9 changed files with 343 additions and 50 deletions

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-12 14:40:09 · 918f330-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-12 15:16:28 · 2de2fdf-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -34,6 +34,11 @@ func IsAdmin(fsRoot, email string) bool {
// dirPath. Authority cascades: a match against any Admins entry on the chain // dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath. // 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:<name>) — 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 // This is the read-side check — "can email *see* admin tools for this
// subtree?". For write authority over a specific .zddc file, use // subtree?". For write authority over a specific .zddc file, use
// CanEditZddc, which adds the strict-ancestor rule that prevents // CanEditZddc, which adds the strict-ancestor rule that prevents
@ -46,9 +51,9 @@ func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
if err != nil { if err != nil {
return false return false
} }
for _, level := range chain.Levels { for i, level := range chain.Levels {
for _, pattern := range level.Admins { for _, principal := range level.Admins {
if MatchesPattern(pattern, email) { if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
return true return true
} }
} }
@ -93,9 +98,12 @@ func CanEditZddc(fsRoot, dirPath, email string) bool {
// Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath. // Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath.
// EffectivePolicy returns levels ordered root (index 0) → leaf (last). // 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 i := 0; i < len(chain.Levels)-1; i++ {
for _, pattern := range chain.Levels[i].Admins { for _, principal := range chain.Levels[i].Admins {
if MatchesPattern(pattern, email) { if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
return true return true
} }
} }

View file

@ -20,6 +20,38 @@ title: "ZDDC"
acl: acl:
permissions: {} 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/<party>/
# 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/<email>/ 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 # Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical # tree), and landing (project picker) work everywhere. Each canonical
# folder below adds its own context-specific tools (mdedit in # folder below adds its own context-specific tools (mdedit in
@ -48,12 +80,36 @@ available_tools: [archive, browse, landing]
paths: paths:
# First segment under root is the project name; "*" matches any. # 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: paths:
archive: archive:
default_tool: archive default_tool: archive
# The doc controller can create party subfolders here
# (archive/<party>/). Restate the full grant — deepest-match
# is per-principal replacement, so we re-list rw + add c.
acl:
permissions:
document_controller: rwc
paths: paths:
# Second segment under archive/ is the party name. # 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: paths:
mdl: mdl:
default_tool: tables default_tool: tables
@ -94,10 +150,13 @@ paths:
# cascade, so a deeper .zddc adds more controllers. # cascade, so a deeper .zddc adds more controllers.
received: received:
default_tool: archive 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: issued:
default_tool: archive default_tool: archive
worm: [] worm: [document_controller]
working: working:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit, classifier] available_tools: [mdedit, classifier]
@ -105,6 +164,12 @@ paths:
# below. # below.
auto_own: true auto_own: true
drop_target: 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/<party>/received|issued still
# binds them.)
admins: [document_controller]
paths: paths:
"*": # per-user home dir "*": # per-user home dir
default_tool: mdedit default_tool: mdedit
@ -121,6 +186,9 @@ paths:
available_tools: [transmittal, classifier] available_tools: [transmittal, classifier]
auto_own: true auto_own: true
drop_target: true drop_target: true
# Doc controller is subtree-admin of staging/ too — same
# rationale as working/.
admins: [document_controller]
reviewing: reviewing:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit] available_tools: [mdedit]

View file

@ -112,7 +112,8 @@ func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
t.Fatalf("ensure: %v", err) 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 { if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
t.Errorf("archive/ not created: %v", err) 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) 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). // archive/ACME/ created WITH auto-own (the cascade declares
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME")); err != nil { // auto_own on the party-folder level so whoever creates a party
t.Errorf("ACME/ not created: %v", err) // 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) { if !strings.Contains(string(pdata), "rep@acme.com: rwcda") {
t.Errorf("ACME/ should not have auto-own .zddc; got err=%v", err) 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. // archive/ACME/incoming/ created WITH auto-own.

View file

@ -73,10 +73,23 @@ func (r ACLRules) InheritsAncestors() bool {
// Role is the named principal-grouping primitive. Members are email // Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see // patterns (same syntax as the legacy allow/deny entries — see
// MatchesPattern). A role defined at level L is in scope at L and all // 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 // descendants.
// role definition by redefining the same name. //
// 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 { type Role struct {
Members []string `yaml:"members,omitempty" json:"members,omitempty"` 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. // ZddcFile represents the parsed contents of a .zddc configuration file.

View file

@ -99,30 +99,21 @@ func IsPrincipalRole(principal string) bool {
return !strings.Contains(principal, "@") return !strings.Contains(principal, "@")
} }
// RoleMembers returns the member-pattern list for roleName as visible // RoleMembers returns the effective member-pattern list for roleName
// at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and // as visible at chain.Levels[levelIdx] — the UNION of every level's
// returns the first definition found (closer-to-leaf wins). The lower // definition in the visible chain, with a role.Reset=true level
// bound is determined by chain.VisibleStart(levelIdx, mode): in // stopping the walk (its members plus anything deeper; ancestors
// delegated mode, an inherit:false fence at-or-below levelIdx hides // above the reset excluded). The visible-chain lower bound is
// any role definitions in levels above it; in strict mode the full // chain.VisibleStart(levelIdx, mode): in delegated mode, an
// chain is visible. Returns nil if no level in the visible chain // inherit:false fence at-or-below levelIdx hides definitions above
// defines the role. // 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 // Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention. // EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string { func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { members, _ := lookupRoleMembers(chain, levelIdx, roleName, mode)
return nil return members
}
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
} }
// MatchesPrincipal reports whether email satisfies the given Permissions // 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" // but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. The // (defined=false), which the principal-fallback logic depends on. The
// visible-chain bound is determined by chain.VisibleStart(levelIdx, mode). // 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) { func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false return nil, false
} }
floor := chain.VisibleStart(levelIdx, mode) 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-- { for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName] role, ok := chain.Levels[i].Roles[roleName]
if !ok { if !ok {
continue 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 // MatchingPrincipals returns the keys of level.ACL.Permissions whose

View file

@ -64,14 +64,14 @@ func TestIsPrincipalRole(t *testing.T) {
} }
} }
func TestRoleMembersClosestLeafWins(t *testing.T) { func TestRoleMembersUnionAcrossCascade(t *testing.T) {
chain := PolicyChain{ chain := PolicyChain{
Levels: []ZddcFile{ Levels: []ZddcFile{
// root: role defined with one set of members // root: role defined with one member
{Roles: map[string]Role{ {Roles: map[string]Role{
"editors": {Members: []string{"alice@example.com"}}, "editors": {Members: []string{"alice@example.com"}},
}}, }},
// child: shadows with a different set // child: ADDS a member (union, not shadow)
{Roles: map[string]Role{ {Roles: map[string]Role{
"editors": {Members: []string{"bob@example.com"}}, "editors": {Members: []string{"bob@example.com"}},
}}, }},
@ -79,13 +79,54 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
HasAnyFile: true, HasAnyFile: true,
} }
got := RoleMembers(chain, 1, "editors", ModeDelegated) got := RoleMembers(chain, 1, "editors", ModeDelegated)
if len(got) != 1 || got[0] != "bob@example.com" { if len(got) != 2 {
t.Errorf("leaf shadow failed: %v", got) 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) got = RoleMembers(chain, 0, "editors", ModeDelegated)
if len(got) != 1 || got[0] != "alice@example.com" { 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)
}
} }
} }

View file

@ -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())
}
}

View file

@ -102,12 +102,24 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.Tables = mergeStringMap(out.Tables, top.Tables) out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display) out.Display = mergeStringMap(out.Display, top.Display)
// Roles: shallow replace if top sets any. Roles are subtree- // Roles: per-name merge (top wins on name clash). This combines
// scoped principal groups; layered merge of named lists would be // the on-disk .zddc at a level with any virtual contributions
// surprising — operators expecting a clean replacement at the // from ancestor paths: at the same level. Cross-LEVEL role
// override level is the conventional pattern. // membership union (and the reset flag) is handled at lookup
// time by lookupRoleMembers, not here.
if len(top.Roles) > 0 { 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 // Paths: top entirely replaces base if set. Recursive descent of