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>
|
||||
<div class="header-title-group">
|
||||
<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 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
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<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
|
||||
# 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/<party>/). 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/<party>/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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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.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
|
||||
|
|
|
|||
Loading…
Reference in a new issue