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>
270 lines
9 KiB
Go
270 lines
9 KiB
Go
package zddc
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ResolveCanonicalPath substitutes on-disk casing for any canonical
|
|
// ancestor segment of target, without creating anything. Returns target
|
|
// unchanged when no case variant exists or when target is at fsRoot or
|
|
// outside it.
|
|
//
|
|
// Use this before authorization to make ACL lookups operate against the
|
|
// real on-disk path without side effects. EnsureCanonicalAncestors
|
|
// performs the same substitution AND creates missing ancestors —
|
|
// authorization should run between the two.
|
|
func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|
rel, err := filepath.Rel(fsRoot, target)
|
|
if err != nil {
|
|
return target, err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
return target, nil
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
resolvedSegs := make([]string, len(parts))
|
|
copy(resolvedSegs, parts)
|
|
|
|
join := func(n int) string {
|
|
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
|
|
return filepath.Join(segs...)
|
|
}
|
|
resolveAt := func(n int, logical string) error {
|
|
parent := join(n)
|
|
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
|
|
return nil
|
|
}
|
|
actual, err := ResolveCanonical(parent, logical)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if actual != "" {
|
|
resolvedSegs[n] = actual
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if len(parts) >= 2 {
|
|
seg := strings.ToLower(parts[1])
|
|
if seg == "archive" {
|
|
if err := resolveAt(1, seg); err != nil {
|
|
return target, err
|
|
}
|
|
}
|
|
}
|
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
|
seg := strings.ToLower(parts[3])
|
|
switch seg {
|
|
case "mdl", "rsk", "incoming", "received", "issued",
|
|
"working", "staging", "reviewing":
|
|
if err := resolveAt(3, seg); err != nil {
|
|
return target, err
|
|
}
|
|
}
|
|
}
|
|
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
}
|
|
|
|
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
|
// creating any missing canonical-folder ancestor with MkdirAll(perm).
|
|
// For freshly-created auto-own ancestors (archive/<party>/, and the per-
|
|
// party lifecycle slots {working,staging,reviewing,incoming}), it also
|
|
// writes a creator-owned .zddc using principalEmail (skipped if
|
|
// principalEmail is empty).
|
|
//
|
|
// Returns the resolved version of target with on-disk casing substituted
|
|
// for any canonical ancestor whose disk variant differs from the requested
|
|
// casing — so a pre-existing Archive/ is reused rather than shadowed by a
|
|
// new archive/ sibling. The basename of target is never altered.
|
|
//
|
|
// Canonical positions, relative to fsRoot:
|
|
//
|
|
// - <project>/archive (the only physical project-root canonical;
|
|
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
|
// aggregators with no on-disk presence — writes targeting them
|
|
// must be rejected by the caller's project-root mkdir guard.)
|
|
// - <project>/archive/<party>/<canonical-party> where
|
|
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
|
// working, staging, reviewing}
|
|
//
|
|
// fsRoot and target must be absolute filesystem paths under the same
|
|
// volume; target may not yet exist on disk.
|
|
func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.FileMode) (string, error) {
|
|
rel, err := filepath.Rel(fsRoot, target)
|
|
if err != nil {
|
|
return target, fmt.Errorf("rel: %w", err)
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
return target, fmt.Errorf("target %q escapes fsRoot %q", target, fsRoot)
|
|
}
|
|
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) < 2 {
|
|
// target is at fsRoot/<single-segment>; no canonical ancestors apply.
|
|
return target, nil
|
|
}
|
|
|
|
// Reject writes targeting top-level virtual aggregators —
|
|
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
|
// resolve through ResolveVirtualView, not as physical paths. A
|
|
// caller writing under them bypassed the virtual resolver.
|
|
if len(parts) >= 2 {
|
|
switch strings.ToLower(parts[1]) {
|
|
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
|
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
|
}
|
|
}
|
|
|
|
resolvedSegs := make([]string, len(parts))
|
|
copy(resolvedSegs, parts)
|
|
|
|
// Track which ancestor directories we end up creating so we can seed
|
|
// auto-own .zddc files on the right ones afterwards.
|
|
type created struct {
|
|
absPath string
|
|
autoOwn bool
|
|
fenced bool
|
|
roles []string
|
|
}
|
|
var freshlyCreated []created
|
|
|
|
// joinUnder builds an absolute path from fsRoot + the first n resolved
|
|
// segments.
|
|
joinUnder := func(n int) string {
|
|
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
|
|
return filepath.Join(segs...)
|
|
}
|
|
|
|
// resolveAt(n) tries to use the on-disk casing for resolvedSegs[n] inside
|
|
// joinUnder(n), substituting if a case-variant directory exists.
|
|
resolveAt := func(n int, logical string) error {
|
|
parent := joinUnder(n)
|
|
// Only substitute if the requested segment doesn't already match
|
|
// on disk (cheap optimisation to avoid a ReadDir on the hot path).
|
|
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
|
|
return nil
|
|
}
|
|
actual, err := ResolveCanonical(parent, logical)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if actual != "" {
|
|
resolvedSegs[n] = actual
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
|
// Depth 0 is the project segment; not a canonical name.
|
|
if len(parts) >= 2 {
|
|
// Depth 1 candidate: archive (only physical project-root canonical).
|
|
seg := strings.ToLower(parts[1])
|
|
if seg == "archive" {
|
|
if err := resolveAt(1, seg); err != nil {
|
|
return target, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Depth 3 candidate (archive/<party>/<canonical-party>): the eight
|
|
// physical per-party slots. Only meaningful when depth 1 is
|
|
// "archive".
|
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
|
seg := strings.ToLower(parts[3])
|
|
switch seg {
|
|
case "mdl", "rsk", "incoming", "received", "issued",
|
|
"working", "staging", "reviewing":
|
|
if err := resolveAt(3, seg); err != nil {
|
|
return target, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now create any missing ancestors. We MkdirAll up to (but not
|
|
// including) the basename. The handler's actual write call still does
|
|
// its own parent-dir creation; this is the proactive seeding pass.
|
|
parentDir := filepath.Dir(filepath.Join(append([]string{fsRoot}, resolvedSegs...)...))
|
|
rootRel, _ := filepath.Rel(fsRoot, parentDir)
|
|
rootRel = filepath.ToSlash(rootRel)
|
|
if rootRel == "." || strings.HasPrefix(rootRel, "../") {
|
|
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
}
|
|
|
|
// Walk segment-by-segment. For each ancestor that doesn't exist yet,
|
|
// create it and record whether the position is auto-own.
|
|
pathSoFar := fsRoot
|
|
parentSegs := strings.Split(rootRel, "/")
|
|
for i, name := range parentSegs {
|
|
pathSoFar = filepath.Join(pathSoFar, name)
|
|
if info, err := os.Stat(pathSoFar); err == nil {
|
|
if !info.IsDir() {
|
|
return target, fmt.Errorf("ancestor %q exists but is not a directory", pathSoFar)
|
|
}
|
|
continue
|
|
} else if !os.IsNotExist(err) {
|
|
return target, err
|
|
}
|
|
|
|
if err := os.MkdirAll(pathSoFar, perm); err != nil {
|
|
return target, err
|
|
}
|
|
|
|
// Determine if this newly-created ancestor is an auto-own
|
|
// position and whether it should be fenced (inherit: false).
|
|
// Resolved via the .zddc cascade — defaults.zddc.yaml
|
|
// carries the canonical "working/staging auto-own + per-user
|
|
// homes fenced + incoming auto-own" convention, and any
|
|
// on-disk .zddc can override per-directory.
|
|
_ = parentSegs // depth-tracking no longer needed
|
|
_ = 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,
|
|
})
|
|
}
|
|
|
|
// Seed auto-own .zddc on the canonical positions that were freshly
|
|
// 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. 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 {
|
|
continue
|
|
}
|
|
var werr error
|
|
if c.fenced {
|
|
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
|
|
} else {
|
|
werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
|
|
}
|
|
if werr != nil {
|
|
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
|
}
|
|
}
|
|
}
|
|
|
|
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
}
|
|
|
|
// (autoOwnDepthMatch / isAutoOwnDepthMatch removed in Phase 3c —
|
|
// auto-own + fence determination now flows through the .zddc cascade
|
|
// via AutoOwnAt / AutoOwnFencedAt.)
|