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>
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package zddc
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultToolAt returns the cascade-resolved default tool name for
|
|
// the directory at dirPath. Empty when no cascade level (on-disk or
|
|
// virtual via Paths) has declared a DefaultTool.
|
|
//
|
|
// Lookup walks chain.Levels from leaf toward root, returning the
|
|
// first non-empty value. This implements the "parent applies to
|
|
// descendants unless overridden" cascade rule: a working/ folder's
|
|
// default_tool=browse propagates to working/alice/notes/ even when
|
|
// no .zddc declares browse at the deeper levels.
|
|
//
|
|
// Used by the URL dispatcher to route no-slash directory URLs.
|
|
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
|
|
func DefaultToolAt(fsRoot, dirPath string) string {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
if dt := chain.Levels[i].DefaultTool; dt != "" {
|
|
return dt
|
|
}
|
|
}
|
|
return chain.Embedded.DefaultTool
|
|
}
|
|
|
|
// DirToolAt returns the cascade-resolved tool name served at the
|
|
// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root
|
|
// (then the embedded defaults), returning the first non-empty
|
|
// DirTool. Floors at "browse": an undeclared directory serves the
|
|
// file-tree navigator, which is the site-wide convention. So callers
|
|
// never need to special-case the empty result.
|
|
//
|
|
// This is the slash half of the slash/no-slash routing convention;
|
|
// DefaultToolAt is the no-slash half.
|
|
func DirToolAt(fsRoot, dirPath string) string {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return "browse"
|
|
}
|
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
if dt := chain.Levels[i].DirTool; dt != "" {
|
|
return dt
|
|
}
|
|
}
|
|
if dt := chain.Embedded.DirTool; dt != "" {
|
|
return dt
|
|
}
|
|
return "browse"
|
|
}
|
|
|
|
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
|
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
|
// propagate to descendants (creating working/alice/notes/sub/ does
|
|
// not auto-own sub/; only the explicitly-declared per-user home is
|
|
// auto-owned).
|
|
func AutoOwnAt(fsRoot, dirPath string) bool {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
leaf := leafLevel(chain)
|
|
if leaf.AutoOwn != nil {
|
|
return *leaf.AutoOwn
|
|
}
|
|
if v := chain.Embedded.AutoOwn; v != nil {
|
|
return *v
|
|
}
|
|
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.
|
|
func AutoOwnFencedAt(fsRoot, dirPath string) bool {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
leaf := leafLevel(chain)
|
|
if leaf.AutoOwnFenced != nil {
|
|
return *leaf.AutoOwnFenced
|
|
}
|
|
if v := chain.Embedded.AutoOwnFenced; v != nil {
|
|
return *v
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DropTargetAt reports whether THIS specific directory accepts
|
|
// drag-drop uploads from a client (e.g. browse's drop-zone overlay).
|
|
// Leaf-only — the property describes a specific path, not a subtree.
|
|
func DropTargetAt(fsRoot, dirPath string) bool {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
leaf := leafLevel(chain)
|
|
if leaf.DropTarget != nil {
|
|
return *leaf.DropTarget
|
|
}
|
|
if v := chain.Embedded.DropTarget; v != nil {
|
|
return *v
|
|
}
|
|
return false
|
|
}
|
|
|
|
// VirtualAt reports whether THIS specific directory is declared as
|
|
// purely virtual (never materialise on disk). Leaf-only: the virtual
|
|
// property describes a particular path, not a subtree. A child of a
|
|
// virtual directory is not automatically virtual itself.
|
|
func VirtualAt(fsRoot, dirPath string) bool {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
leaf := leafLevel(chain)
|
|
if leaf.Virtual != nil {
|
|
return *leaf.Virtual
|
|
}
|
|
if v := chain.Embedded.Virtual; v != nil {
|
|
return *v
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsDeclaredPath reports whether dirPath is mentioned in the
|
|
// cascade — either by an on-disk .zddc at that level OR by any
|
|
// ancestor's paths: tree (including the embedded defaults).
|
|
//
|
|
// A declared path is one the cascade has *something to say about*
|
|
// even if the directory doesn't exist on disk. Used by listing
|
|
// fallbacks to decide whether a missing directory should return an
|
|
// empty listing (treat as virtual) vs 404 (truly unknown).
|
|
func IsDeclaredPath(fsRoot, dirPath string) bool {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if len(chain.Levels) == 0 {
|
|
return false
|
|
}
|
|
leaf := leafLevel(chain)
|
|
// A non-empty merged level at the leaf means at least one
|
|
// contribution reached here — either an on-disk file, or an
|
|
// ancestor's paths: glob matched.
|
|
return !isZeroZddcFile(leaf)
|
|
}
|
|
|
|
// AvailableToolsAt returns the cascade-unioned list of tool names
|
|
// the server may auto-serve at this directory. Built by walking
|
|
// chain.Levels from leaf to root, then the embedded defaults, and
|
|
// concat-deduping each level's AvailableTools.
|
|
//
|
|
// Empty result means no auto-routed tools are allowed at this path.
|
|
// The dispatcher gates auto-route fallbacks against this list;
|
|
// explicit static <dir>/tool.html files bypass the gate.
|
|
func AvailableToolsAt(fsRoot, dirPath string) []string {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
add := func(list []string) {
|
|
for _, t := range list {
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
add(chain.Levels[i].AvailableTools)
|
|
}
|
|
add(chain.Embedded.AvailableTools)
|
|
return out
|
|
}
|
|
|
|
// IsToolAvailableAt reports whether tool is in the cascade's
|
|
// available-tools union at dirPath. The dispatcher gates apps-
|
|
// subsystem auto-route on this; explicit on-disk <tool>.html files
|
|
// always serve regardless.
|
|
func IsToolAvailableAt(fsRoot, dirPath, tool string) bool {
|
|
for _, t := range AvailableToolsAt(fsRoot, dirPath) {
|
|
if t == tool {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ChildrenDeclaredAt returns the set of child directory names that
|
|
// the cascade declares should exist under dirPath. Includes
|
|
// wildcard "*" specs (caller decides how to expose those) and
|
|
// literal names. Used by fs.ListDirectory to inject virtual
|
|
// canonical-folder entries at a project root.
|
|
//
|
|
// Returns the literal names; "*" wildcards are NOT included
|
|
// (callers can't synthesise a meaningful name for a wildcard).
|
|
func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
leaf := leafLevel(chain)
|
|
if len(leaf.Paths) == 0 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for k := range leaf.Paths {
|
|
if k == "*" {
|
|
continue
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CanonicalFolderAt returns the canonical-folder name for THIS specific
|
|
// directory — one of "archive", "working", "staging", "reviewing",
|
|
// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path
|
|
// is not at a canonical-folder slot.
|
|
//
|
|
// Detection is structural against the canonical project layout declared
|
|
// in defaults.zddc.yaml:
|
|
//
|
|
// - top-level <project>/archive is the only physical project-root
|
|
// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs
|
|
// at project root are virtual aggregators, not on-disk folders).
|
|
// - third-level archive/<party>/{mdl,rsk,incoming,received,issued,
|
|
// working,staging,reviewing} are the physical per-party canonical
|
|
// slots.
|
|
//
|
|
// Operators don't rename these slots (the cascade keys them by
|
|
// literal name); a custom layout that does is on its own.
|
|
//
|
|
// Used by the browse SPA to scope-gate context-menu actions (Accept,
|
|
// Stage/Unstage, Create Transmittal folder) without re-implementing the
|
|
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
|
|
func CanonicalFolderAt(fsRoot, dirPath string) string {
|
|
segs := resolvePathSegments(fsRoot, dirPath)
|
|
// <project>/<folder> — only archive/ is physical at project root.
|
|
if len(segs) == 2 {
|
|
if segs[1] == "archive" {
|
|
return "archive"
|
|
}
|
|
return ""
|
|
}
|
|
// <project>/archive/<party>/<folder>
|
|
if len(segs) == 4 && segs[1] == "archive" {
|
|
switch segs[3] {
|
|
case "incoming", "received", "issued", "mdl", "rsk",
|
|
"working", "staging", "reviewing":
|
|
return segs[3]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
|
|
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
|
// returns ZddcFile{} on empty for ergonomic chaining.
|
|
func leafLevel(chain PolicyChain) ZddcFile {
|
|
if len(chain.Levels) == 0 {
|
|
return ZddcFile{}
|
|
}
|
|
return chain.Levels[len(chain.Levels)-1]
|
|
}
|
|
|
|
// isZeroZddcFile reports whether zf carries no meaningful content.
|
|
// Used by IsDeclaredPath to distinguish "ancestor paths: matched
|
|
// and stamped something here" from "no contribution at all".
|
|
func isZeroZddcFile(zf ZddcFile) bool {
|
|
if zf.Title != "" {
|
|
return false
|
|
}
|
|
if zf.DefaultTool != "" || zf.DirTool != "" {
|
|
return false
|
|
}
|
|
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||
|
|
zf.DropTarget != nil || zf.Inherit != nil {
|
|
return false
|
|
}
|
|
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
|
zf.PlannedResponseDate != "" {
|
|
return false
|
|
}
|
|
if len(zf.AvailableTools) > 0 {
|
|
return false
|
|
}
|
|
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
|
|
return false
|
|
}
|
|
if zf.Worm != nil { // non-nil even when empty — marks a WORM zone
|
|
return false
|
|
}
|
|
if len(zf.Admins) > 0 {
|
|
return false
|
|
}
|
|
if len(zf.ACL.Permissions) > 0 {
|
|
return false
|
|
}
|
|
if zf.ACL.Inherit != nil {
|
|
return false
|
|
}
|
|
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
|
|
return false
|
|
}
|
|
if len(zf.Roles) > 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// resolvePathSegments turns dirPath (absolute, under fsRoot) into a
|
|
// slice of segments relative to fsRoot. Used by helpers that walk
|
|
// the cascade by segment. Returns nil for dirPath == fsRoot or for
|
|
// any path outside fsRoot.
|
|
func resolvePathSegments(fsRoot, dirPath string) []string {
|
|
fsRoot = filepath.Clean(fsRoot)
|
|
dirPath = filepath.Clean(dirPath)
|
|
if dirPath == fsRoot {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(fsRoot, dirPath)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return nil
|
|
}
|
|
return strings.Split(rel, string(filepath.Separator))
|
|
}
|