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:
parent
2de2fdf92c
commit
54dff4dcd3
9 changed files with 343 additions and 50 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
zddc/internal/zddc/standardroles_test.go
Normal file
120
zddc/internal/zddc/standardroles_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue