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>
103 lines
3.8 KiB
Go
103 lines
3.8 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// The .zddc cascade is the authority for canonical-folder behaviour;
|
|
// see defaults.zddc.yaml for the conventions and lookups.go for the
|
|
// helpers consumers call (DefaultToolAt, AutoOwnAt, VirtualAt,
|
|
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
|
|
|
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
|
// 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 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, roles []string) error {
|
|
return writeAutoOwn(dir, principalEmail, false, roles)
|
|
}
|
|
|
|
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
|
// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at
|
|
// per-user home folders under working/ where the convention is "private
|
|
// by default; owner edits the file to add collaborators."
|
|
//
|
|
// 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.
|
|
//
|
|
// 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, 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
|
|
}
|
|
zf := ZddcFile{
|
|
ACL: rules,
|
|
CreatedBy: principalEmail,
|
|
}
|
|
return WriteFile(dir, zf)
|
|
}
|
|
|
|
// ResolveCanonical returns the on-disk name of the canonical folder
|
|
// 'logical' (lowercase) inside parentDir, or "" if no case variant
|
|
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
|
|
// when "" is returned.
|
|
//
|
|
// 'logical' is matched case-insensitively against entries returned by
|
|
// os.ReadDir(parentDir). The first matching directory entry wins (if
|
|
// an operator created both Working/ and working/ on a case-sensitive
|
|
// filesystem, the order is filesystem-dependent — that's an unsupported
|
|
// state we don't try to recover from).
|
|
//
|
|
// Returns "" with no error if parentDir doesn't exist or has no match.
|
|
func ResolveCanonical(parentDir, logical string) (string, error) {
|
|
entries, err := os.ReadDir(parentDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if strings.EqualFold(e.Name(), logical) {
|
|
return e.Name(), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|