feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin

Two related schema/defaults changes that together replace the
admins:[document_controller] subtree-admin status with a cleaner
role-grant-via-auto-own model, and lock down the one-way handoff
through the in-flight lifecycle slots.

## New: auto_own_roles

ZddcFile.AutoOwnRoles []string is a new field on the parent's .zddc
declaring "when this directory's auto_own fires, also grant these
roles rwcda alongside the creator email". The writer
(WriteAutoOwnZddc + WriteAutoOwnZddcFenced) now takes a roles slice
and writes both the creator email AND each named role as rwcda in
the new .zddc. mergeOverlay treats AutoOwnRoles like other path-tree
contributions (leaf-wins).

The defaults' archive/<party>/ entry now sets
`auto_own_roles: [document_controller]` and drops the
`admins: [document_controller]` line:

  - When any DC mkdir's archive/<party>/, the auto-own .zddc grants
    both their email and the role rwcda. Peer DCs share full
    authority at every party without any DC needing subtree-admin
    status.
  - DCs are no longer subtree-admins anywhere. They can't bypass
    WORM (only worm-create via the worm: list) and can't reach
    inside fenced working homes. Admin elevation is reserved for
    the root admins: list.
  - Plan Review's ActionAdmin pre-flight passes for any DC via the
    role grant cascading into reviewing/ and staging/.

## In-flight ratchet (working → staging → issued)

Per-role grants at the lifecycle slots formalise a one-way handoff:

  working/   project_team: cr (create their own folders;
                              auto_own_fenced gives rwcda inside)
  staging/   project_team: cr (drop files, no modify after — the
                              "commit" step; DC takes over)
             document_controller: rwcd (transfer-to-issued needs `d`)
  reviewing/ project_team: cr (create iteration folders; auto_own
                              unfenced grants rwcda inside)
  received/  worm cr (file write-once)
  issued/    worm cr

Each handoff drops the previous role's modify rights for the slot
they pushed from. Comments in defaults.zddc.yaml document the
pattern + the "project_team drops files at staging root, never
mkdirs" convention.

## Tests

TestStandardRoles_DocControllerScopedCreate rewritten — flips
from IsSubtreeAdmin assertions to verifying:
  - rwcda at <party>/ via the auto-own .zddc (creator + role)
  - rwcda cascading to working/reviewing/ (no slot override)
  - rwcd at incoming/staging/ via explicit grants
  - cr at received/issued via WORM mask
  - IsSubtreeAdmin = false everywhere
  - DC blocked from alice's fenced working/<email>/ home

New TestStandardRoles_DocControllerMultiDC — a second DC in the
role gets the same rwcda at any party a peer created, via the role
grant in auto_own_roles.

New TestStandardRoles_ProjectTeamInFlightRatchet locks the ratchet:
project_team gets cr at working/staging/reviewing, r at incoming/
received/issued.

New TestStandardRoles_DocControllerStagingDelete confirms DC has
`d` at staging/ for the transfer-to-issued workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 10:51:07 -05:00
parent b5a725e745
commit ba98b87b2a
9 changed files with 404 additions and 85 deletions

View file

@ -758,11 +758,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// unfenced so ancestor grants still cascade through.
if email != "" {
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
werr = zddc.WriteAutoOwnZddcFenced(abs, email)
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
} else {
werr = zddc.WriteAutoOwnZddc(abs, email)
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
}
if werr != nil {
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)

View file

@ -145,11 +145,12 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
// fires here exactly as it would for a manual mkdir.
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
} else {
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
}
if werr != nil {
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)

View file

@ -34,22 +34,53 @@ acl:
# 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 the
# per-party working/ + staging/ + reviewing/ so they can stand up
# and manage drafting/transmittal/review folders. They are NOT
# subtree-admin of archive/<party>/, so the WORM constraint still
# binds them in received/issued. Plan-Review approval is part of
# this role by design — there is no separate `approver` role;
# two-person sign-off, when needed, is expressed via per-folder
# `.zddc` overrides rather than baked-in roles.
# archive/<party>/received/ and issued/ (WORM zones). They get:
# - rwcda at every archive/<party>/ via the role grant written
# into each party's auto-own .zddc (auto_own_roles below).
# Cascade carries rwcda down to descendants by default.
# - read+write-once-create at received/issued via the worm:
# lists (the WORM mask strips w/d/a even though the role
# grant supplies rwcda at the party level above).
# - rwcd explicit at incoming/ and staging/ (the QC and
# transmittal-out workflows need `d` to move files between
# slots; the explicit grants shadow the inherited rwcda
# to make the intent visible).
# - rwc at archive/ so they can create party subfolders.
#
# NOT a subtree-admin anywhere. There is no `admins:` entry for
# the role — DCs cannot bypass WORM (only worm-create via the
# list) and cannot reach inside fenced working homes. Admin
# elevation is reserved for the root admins: list (the human
# escape hatch for mis-filed documents or recovery).
#
# Plan-Review approval is part of this role by design — there is
# no separate `approver` role; two-person sign-off, when needed,
# is expressed via per-folder `.zddc` overrides rather than
# baked-in roles.
#
# project_team — everyone working on a project. Read-only across
# the project. Their own archive/<party>/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.
# the project by default, with a one-way ratchet through the
# in-flight slots:
#
# working/ cr — create + read; the auto_own_fenced child
# gives the creator rwcda in their own home,
# fenced from siblings
# staging/ cr — drop + read, no modify (after drop, the
# doc-controller is the only one who can
# change it)
# reviewing/ cr — create + read; auto_own (unfenced) gives
# creator rwcda in their iteration folder,
# siblings see it via project-level :r
# received/ r — WORM zone; only document_controller can
# file (and even they need elevation to edit)
# issued/ r — WORM zone; published, immutable
# incoming/ r — counterparty's drop zone (project_team
# observers it, doc_controller QCs it)
#
# "Each handoff drops the role's modify rights for the previous
# slot." That's the model — project_team works freely in
# working/, commits to staging/, and from there the doc-
# controller takes over.
#
# observer — pure read-only across the project. Like project_team
# but with no auto-own home: an observer who somehow created a
@ -197,19 +228,31 @@ paths:
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.
# When the doc controller creates a party folder, the
# auto-own .zddc grants:
# - the creator's email rwcda (the standard auto_own
# mechanism)
# - the document_controller role rwcda (auto_own_roles
# below) so any DC in the role has full authority at
# every party, not just the parties they personally
# mkdir'd
#
# UNFENCED — so the project-level project_team:r still
# cascades through to received/issued/incoming. That
# lets the DC who created the party set up the counter-
# party's own .zddc afterward (e.g. granting them cr at
# incoming/).
#
# No `admins:` here by design. The DC role gets full
# authority via the role grant in the auto-own .zddc, not
# via subtree-admin status — so WORM masks at
# received/issued still bind them (they file write-once
# via the worm: list), and per-user fenced homes under
# working/ stay private to their creators. Admin
# elevation is reserved for the root admins list (the
# actual sudo-style escape hatch).
auto_own: true
# Doc controller is subtree-admin of this party folder —
# full manage authority over the in-flight lifecycle
# slots (working/staging/reviewing) declared below. The
# WORM constraint on received/issued is enforced by the
# cascade's worm: lists, not by admin grants, so they
# still file write-once into those slots.
admins: [document_controller]
auto_own_roles: [document_controller]
# SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to
@ -324,9 +367,39 @@ paths:
# level <project>/{working,staging,reviewing} virtuals
# (declared above) are folder-nav views over these
# canonical per-party slots.
# ── In-flight ratchet ───────────────────────────────
#
# The lifecycle slots form a one-way handoff:
#
# working/ → staging/ → issued/ (WORM)
# (full) (cr) (worm cr)
#
# At each step the previous role's modify rights drop:
# project_team iterates freely in working/; when they
# promote to staging/ they can't change it without doc-
# controller help; when DC publishes to issued/ even
# they can't change it without elevation. Each ACL
# grant below is the verb-set the ROLE keeps at that
# step; auto_own + auto_own_fenced sub-folder grants
# layer per-creator ownership on top of these.
working:
default_tool: browse
available_tools: [browse, classifier]
# Project_team gets read + create here so they can
# mkdir their own home folder (and any shared sub-
# folders). The auto_own_fenced declaration at the
# `*` child below makes the new folder a private home
# with rwcda for the creator (fenced from ancestors,
# so collaborators only join after the owner edits
# the home's .zddc to grant them access).
#
# `cr` instead of just `c` so an existing file at
# working/ root stays readable to all team members
# (cascade is per-level deepest-match — a single `c`
# would shadow the project-level `r`).
acl:
permissions:
project_team: cr
# working/ auto-owns the first creator + the per-user
# homes below.
auto_own: true
@ -345,6 +418,29 @@ paths:
staging:
default_tool: transmittal
available_tools: [transmittal, classifier]
# The ratchet step from working/. project_team gets
# `cr` — they can drop files (PUT new files at
# staging/) and read what's there, but cannot edit or
# delete after the drop. Once a file is in staging it
# belongs to the doc-controller workflow; the team
# member needs to ask DC to change it.
#
# Convention: project_team drops FILES at staging/,
# not sub-folders. A sub-folder mkdir'd by project_
# team would trigger auto_own and grant them rwcda
# inside their own sub-folder (auto_own is path-keyed,
# not role-keyed — it fires for any creator). The
# auto_own here is preserved for DC's per-transmittal
# mkdir flow; project_team can keep to file drops to
# honour the "can't alter after" intent.
#
# DC gets rwcd explicitly — the staging-to-issued
# transfer needs `d` (cut, not copy) to move files
# out. Mirrors the incoming/ pattern at line 286-288.
acl:
permissions:
project_team: cr
document_controller: rwcd
auto_own: true
drop_target: true
reviewing:
@ -359,5 +455,15 @@ paths:
# from the party-level admins:) so the doc
# controller can author per-folder .zddc files
# (originator ACL, planned_date).
#
# project_team gets `cr` so the originating team can
# create review-iteration folders alongside the
# Plan-Review-scaffolded ones. auto_own (unfenced
# here, unlike working/) gives the creator rwcda
# inside; siblings see the iteration via the project-
# level project_team:r cascade.
acl:
permissions:
project_team: cr
auto_own: true
drop_target: true

View file

@ -130,6 +130,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
absPath string
autoOwn bool
fenced bool
roles []string
}
var freshlyCreated []created
@ -224,10 +225,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
_ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
var roles []string
if autoOwn {
roles = AutoOwnRolesAt(fsRoot, pathSoFar)
}
freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar,
autoOwn: autoOwn,
fenced: fenced,
roles: roles,
})
}
@ -235,7 +241,10 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// created. Skip if no principal email is available (anonymous or
// system writes). The fenced variant is used at per-user home
// folders under working/ — private by default; owner can later
// edit the .zddc to add collaborators.
// edit the .zddc to add collaborators. Role grants (from the
// cascade's auto_own_roles list) are written alongside the
// creator email so role-level peer authority survives without
// needing a subtree-admin grant.
if principalEmail != "" {
for _, c := range freshlyCreated {
if !c.autoOwn {
@ -243,9 +252,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
}
var werr error
if c.fenced {
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail)
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
} else {
werr = WriteAutoOwnZddc(c.absPath, principalEmail)
werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
}
if werr != nil {
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)

View file

@ -231,6 +231,23 @@ type ZddcFile struct {
// admin grants still apply.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
// the auto-own .zddc written at a new child directory grants each
// listed role `rwcda` ALONGSIDE the creator email. Lets the schema
// express "the creator owns it AND any member of these roles has
// full authority" without resorting to a separate admins: list
// (which would be subtree-admin and bypass WORM / fences via
// elevation — too strong for typical workflows).
//
// Example: archive/<party>/ sets `auto_own_roles: [document_controller]`
// so any DC has rwcda at every party folder a peer created, not
// just at parties they personally mkdir'd.
//
// Grants are written as plain permissions in the new .zddc — they
// have no special semantic beyond what `rwcda` already means in
// the cascade. A fence (auto_own_fenced) still binds them.
AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,omitempty"`
// Virtual marks a directory as never-materialise-on-disk. The
// server treats requests under such a path as virtual routes
// rather than triggering EnsureCanonicalAncestors. The reviewing

View file

@ -75,6 +75,23 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
return false
}
// AutoOwnRolesAt returns the role names that should be granted rwcda
// in the auto-own .zddc at this dir (alongside the creator's email).
// Leaf-only, same semantic as AutoOwnAt / AutoOwnFencedAt. Empty/nil
// when the cascade declares no role grants — the legacy creator-only
// behavior. Caller passes the result to WriteAutoOwnZddc / Fenced.
func AutoOwnRolesAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
leaf := leafLevel(chain)
if leaf.AutoOwnRoles != nil {
return leaf.AutoOwnRoles
}
return chain.Embedded.AutoOwnRoles
}
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
// should be written with `inherit: false` (private to creator).
// Leaf-only, same semantic as AutoOwnAt.

View file

@ -11,19 +11,24 @@ import (
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
// principalEmail rwcda and recording it in CreatedBy. Used by the file
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
// ownership when a new auto-own folder is materialised.
// principalEmail rwcda and recording it in CreatedBy. Each role name
// in roles also receives rwcda — gives the schema a way to declare
// "this folder is creator-owned AND any member of these roles has full
// authority" without using subtree-admin (which would bypass WORM /
// fences via elevation). Used by the file API's mkdir post-hook (and
// by EnsureCanonicalAncestors) to seed ownership when a new auto-own
// folder is materialised. Pass nil/empty roles for the legacy
// creator-only behavior.
//
// The grant is identical to what an operator would write by hand —
// direct email pattern, "rwcda" verb set — so the creator can later
// edit the file normally to add collaborators.
// The grants are identical to what an operator would write by hand —
// direct email pattern + bare role names, "rwcda" verb set — so the
// creator can later edit the file normally to narrow or extend them.
//
// Atomic: marshals via the same yaml encoder ParseFile reads
// (round-trip guaranteed) and writes via zddc.WriteFile (which
// performs an atomic temp-write + rename via zddc.WriteAtomic).
func WriteAutoOwnZddc(dir, principalEmail string) error {
return writeAutoOwn(dir, principalEmail, false)
func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, false, roles)
}
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
@ -34,14 +39,26 @@ func WriteAutoOwnZddc(dir, principalEmail string) error {
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
// authenticated users) would let any user read every other user's
// working subfolder via cascade — defeating the per-user sandbox.
func WriteAutoOwnZddcFenced(dir, principalEmail string) error {
return writeAutoOwn(dir, principalEmail, true)
//
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
// alongside the creator, and like the creator grant they're INSIDE
// the fence (only resolvable if the role is defined at this level or
// in chain.Embedded, since ancestor role definitions are hidden by
// inherit:false). Typically callers using the fenced variant pass nil
// roles — per-user homes don't need peer authority.
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, true, roles)
}
func writeAutoOwn(dir, principalEmail string, fenced bool) error {
rules := ACLRules{
Permissions: map[string]string{principalEmail: "rwcda"},
func writeAutoOwn(dir, principalEmail string, fenced bool, roles []string) error {
perms := map[string]string{principalEmail: "rwcda"}
for _, role := range roles {
if role == "" || role == principalEmail {
continue // skip empty / collision with the creator entry
}
perms[role] = "rwcda"
}
rules := ACLRules{Permissions: perms}
if fenced {
f := false
rules.Inherit = &f

View file

@ -6,29 +6,51 @@ import (
"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)
// TestStandardRoles_DocControllerScopedCreate — DC authority comes
// PURELY from the cascade now (no subtree-admin / admins: list). The
// model:
// - rw at the project level (read + overwrite-existing, no `c`)
// - rwc at archive/ (can create party subfolders)
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
// slots under the party inherit the admin grant)
// - inside received/issued (WORM): masked to r + worm-restored c
//
// Layout reshape: working/staging/reviewing moved from project root
// into archive/<party>/, so the subtree-admin scope likewise moved
// from project-level "working/staging/" to the per-party folder.
// - When DC mkdirs archive/<party>/, ensure.go writes an auto-own
// .zddc granting both the creator email AND the document_controller
// role rwcda there (via auto_own_roles in defaults). This test
// simulates that .zddc directly so the cascade behaviour can be
// asserted in isolation.
// - From the party's auto-own .zddc, the role rwcda cascades down to
// descendants by default; explicit slot grants (rwcd at incoming/
// and staging/) shadow it where the workflow needs `d`.
// - At received/issued (WORM): the WORM mask strips w/d/a from the
// inherited rwcda; the worm: list restores c → effective cr.
// - NOT subtree-admin anywhere — no admins: entries for the role.
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.
// Note: project_team's wildcard would normally also match dc@,
// which would shadow the role's rwcda at working/ (where the slot
// explicitly grants project_team:cr). Real deployments keep
// document_controller and project_team disjoint; the test fixture
// mirrors that.
writeZddc(t, root, `roles:
document_controller:
members: ["dc@example.com"]
project_team:
members: ["*@example.com"]
members: ["alice@example.com"]
`)
// Simulate the auto-own .zddc the file API writes when DC mkdir's
// archive/Acme/. Carries the creator email + the document_controller
// role per the embedded defaults' auto_own_roles entry.
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
writeZddc(t, partyDir, `acl:
permissions:
"dc@example.com": rwcda
document_controller: rwcda
created_by: dc@example.com
`)
resetCache()
dc := "dc@example.com"
mustVerbs := func(dir string, want string) {
@ -37,7 +59,6 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
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); inWorm {
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
@ -55,36 +76,92 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
// archive/: rwc (can create party folders).
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
// incoming/: rwcd — the QC + transfer-out workflow needs delete.
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd")
// 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")
// At the party folder itself: rwcda via the auto-own role grant.
mustVerbs(partyDir, "rwcda")
// Lifecycle slots inside the party folder inherit rwcda from the
// party-level role grant where no slot-local grant overrides.
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda")
// incoming/ and staging/ have explicit document_controller: rwcd
// — leaf-wins shadows the rwcda inherited from <party>/.
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
mustVerbs(filepath.Join(partyDir, "staging"), "rwcd")
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
mustVerbs(filepath.Join(partyDir, "received"), "rc")
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
// Subtree-admin at archive/<party>/ (the embedded cascade
// declares admins: [document_controller] on the party "*" entry,
// so working/staging/reviewing inside the party inherit it).
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
// NOT subtree-admin anywhere — even when notionally elevated,
// the role carries no admin: grant.
for _, p := range []string{
filepath.Join(root, "Proj"),
filepath.Join(root, "Proj", "archive"),
partyDir,
filepath.Join(partyDir, "working"),
filepath.Join(partyDir, "staging"),
filepath.Join(partyDir, "reviewing"),
filepath.Join(partyDir, "received"),
filepath.Join(partyDir, "issued"),
} {
if IsSubtreeAdmin(root, p, Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should NOT be subtree-admin of %s (no admins: list anywhere)", p[len(root):])
}
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
}
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
// And specifically — they CAN'T reach inside a fenced per-user
// working home. The fence isolates team-member workspaces from
// every other role (including DC) by design.
homeDir := filepath.Join(partyDir, "working", "alice@example.com")
if err := os.MkdirAll(homeDir, 0o755); err != nil {
t.Fatal(err)
}
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
writeZddc(t, homeDir, `acl:
inherit: false
permissions:
"alice@example.com": rwcda
created_by: alice@example.com
`)
resetCache()
chain, _ := EffectivePolicy(root, homeDir)
if got := EffectiveVerbs(chain, dc); got != 0 {
t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String())
}
// NOT subtree-admin of archive/ (so WORM still binds them at the
// received/issued slots below).
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
}
// Subtree-admin reaches inside a fenced per-user working home
// under the party's working slot.
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
// TestStandardRoles_DocControllerMultiDC — a second DC added to the
// role gets the SAME rwcda at every party that any DC created,
// because the auto-own .zddc grants the role (not just the creator's
// email) via auto_own_roles in defaults.
func TestStandardRoles_DocControllerMultiDC(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
document_controller:
members: ["dc1@example.com", "dc2@example.com"]
`)
// dc1 created the party folder; the auto-own .zddc lists both
// dc1 (creator email) and the document_controller role (from
// auto_own_roles in defaults).
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
writeZddc(t, partyDir, `acl:
permissions:
"dc1@example.com": rwcda
document_controller: rwcda
created_by: dc1@example.com
`)
resetCache()
chain, _ := EffectivePolicy(root, partyDir)
// dc1 (creator) has rwcda directly.
if got := EffectiveVerbs(chain, "dc1@example.com"); got.String() != "rwcda" {
t.Errorf("dc1 (creator) at party = %q, want rwcda", got.String())
}
// dc2 (non-creator) ALSO has rwcda via the role grant. This is
// the whole point of auto_own_roles — peer DCs share authority
// without admin status.
if got := EffectiveVerbs(chain, "dc2@example.com"); got.String() != "rwcda" {
t.Errorf("dc2 (peer) at party = %q, want rwcda (role grant from auto_own_roles)", got.String())
}
}
@ -141,6 +218,73 @@ created_by: alice@example.com
}
}
// TestStandardRoles_ProjectTeamInFlightRatchet — the one-way handoff
// from working/ → staging/ → issued/ as the team member sees it:
// full work in working (cr at the slot + rwcda inside the fenced
// home), drop-only in staging (cr — no modify after the drop), drop
// inside auto-own iteration folder in reviewing (cr at the slot,
// rwcda inside the auto-owned sub-folder), read-only in received/
// issued (WORM zones) and incoming/ (counterparty drop zone).
func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
project_team:
members: ["*@example.com"]
`)
alice := "alice@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)
}
var got VerbSet
if g, inWorm := WormZoneGrant(chain, alice); inWorm {
got = (EffectiveVerbs(chain, alice) & VerbR) | (g & VerbsRC)
} else {
got = EffectiveVerbs(chain, alice)
}
if got.String() != want {
t.Errorf("project_team alice at %s = %q, want %q", dir[len(root):], got.String(), want)
}
}
party := filepath.Join(root, "Proj", "archive", "Acme")
mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot
mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify
mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders
mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create
mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same
mustVerbs(filepath.Join(party, "incoming"), "r") // counterparty drop zone — read only
}
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
// staging/ to perform the staging-to-issued transfer (cut, not copy);
// the explicit document_controller: rwcd grant supplies it. Mirrors
// the incoming/ pattern (line 286-288 of defaults.zddc.yaml) where
// the QC + transfer-out workflow needed the same.
func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
document_controller:
members: ["dc@example.com"]
`)
dc := "dc@example.com"
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"))
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
got := EffectiveVerbs(chain, dc)
for _, v := range []VerbSet{VerbR, VerbW, VerbC, VerbD} {
if !got.Has(v) {
t.Errorf("doc controller at staging/ missing verb %q in %q (need rwcd for transfer-to-issued)", v.String(), got.String())
}
}
}
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
// project-wide read-only role for auditors / regulators / external
// viewers. Unlike project_team, an observer must not contribute

View file

@ -78,6 +78,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.AutoOwnFenced != nil {
out.AutoOwnFenced = top.AutoOwnFenced
}
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
// declaring an empty list replaces (and explicitly suppresses)
// the ancestor's role list. This matches the leaf-wins semantic
// for the other path-tree contribution lists.
if top.AutoOwnRoles != nil {
out.AutoOwnRoles = top.AutoOwnRoles
}
if top.DropTarget != nil {
out.DropTarget = top.DropTarget
}