refactor(zddc): worm: is a list of principals, not a {principal: verbs} map

Per design feedback: the verb string in a worm: entry was always
effectively "cr" (the key's whole job is to restore write-once-create
inside the locked zone, and you need read to see what you filed), so
spelling it out per-entry was redundant. worm: is now just a list of
principal patterns — email-globs, @role:name, or bare role names —
and every listed principal gets read + write-once-create. An empty
list ([]) still marks the WORM zone with no create-capable
principals.

Changes:
  - ZddcFile.Worm: map[string]string → []string
  - mergeOverlay: concat-dedupe (a deeper .zddc adds controllers);
    mergeStringSlicePreserveEmpty keeps `worm: []` non-nil through
    the overlay so it still marks the zone
  - WormZoneGrant: walks the list, grants VerbsRC to each matching
    principal; result is always ⊆ {r, c}
  - ValidateFile: validates each entry as an email-glob (role refs
    skipped — validated by the role machinery)
  - defaults.zddc.yaml: received/ and issued/ carry `worm: []`
  - tests updated to the list form (worm_test.go, fileapi_test.go)

All Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 09:40:15 -05:00
parent 918f330a6f
commit 2de2fdf92c
8 changed files with 102 additions and 121 deletions

View file

@ -511,13 +511,10 @@ func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
} }
// Operator places an explicit worm: grant at archive/Acme/issued/.zddc // Operator names the document-controller role in the issued/ WORM
// naming the document-controller role. That principal then gets // zone. That role's members then get {r, c} there — the embedded
// {r, c} inside the WORM zone — the embedded `worm: {}` (no // `worm: []` (no controllers) is unioned with this deeper grant.
// controllers) is unioned with this deeper grant. issuedZ := []byte("worm:\n - _doc_controller\n")
issuedZ := []byte(`worm:
_doc_controller: cr
`)
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("write issued .zddc: %v", err) t.Fatalf("write issued .zddc: %v", err)
} }
@ -605,8 +602,9 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
_, do, root := rolePermissionsTestSetup(t) _, do, root := rolePermissionsTestSetup(t)
// Place an explicit worm: grant so dc has cr in the issued WORM zone. // Name the document-controller role in the issued/ WORM zone so its
issuedZ := []byte("worm:\n _doc_controller: cr\n") // members get cr there.
issuedZ := []byte("worm:\n - _doc_controller\n")
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("seed issued .zddc: %v", err) t.Fatalf("seed issued .zddc: %v", err)
} }

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <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 13:28:43 · 9c7858c-dirty</span></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>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -71,34 +71,33 @@ paths:
# Browse shows a drag-drop overlay here. # Browse shows a drag-drop overlay here.
drop_target: true drop_target: true
# received/ and issued/ are WORM (write-once-read-many). # received/ and issued/ are WORM (write-once-read-many).
# The `worm:` key marks the zone: # The `worm:` list marks the zone:
# #
# - write (w) and delete (d) are stripped for EVERYONE # - write (w) and delete (d) are stripped for EVERYONE
# - create (c) is stripped for everyone EXCEPT the # - create (c) is stripped for everyone EXCEPT the
# principals listed below (none, in the baseline) # principals listed — they get read + write-once-
# - read (r) is whatever the normal cascade ACL granted # create ("cr")
# — the WORM key does NOT itself grant read, so a # - read for non-listed principals is whatever the
# deployment that restricts read keeps that # normal cascade ACL granted; the WORM list does not
# restriction; one that grants read to the project # itself confer read to outsiders
# keeps that too # - admins (root / subtree) bypass entirely — the
# - admins bypass entirely (handler IsAdmin escape) # human escape hatch for mis-filed documents
# #
# The empty map ({}) means "WORM, no create-capable # The baseline is an empty list: WORM zone, no
# principals". A deployment names its document controller # create-capable principals — filing is locked until a
# by placing a .zddc at received/ (or issued/, or # deployment names a document controller, e.g.
# archive/<party>/, or wherever scopes it right) with:
# #
# worm: # worm: ["doc-control@example.com"]
# "doc-control@example.com": cr # read + write-once
# #
# worm: grants UNION across the cascade, so multiple # at received/ (or issued/, or archive/<party>/, or
# controllers (or a deeper-scoped one) compose. # wherever scopes it right). worm: lists UNION across the
# cascade, so a deeper .zddc adds more controllers.
received: received:
default_tool: archive default_tool: archive
worm: {} worm: []
issued: issued:
default_tool: archive default_tool: archive
worm: {} worm: []
working: working:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit, classifier] available_tools: [mdedit, classifier]

View file

@ -210,32 +210,31 @@ type ZddcFile struct {
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
// Worm marks this directory (and its descendants) as // Worm marks this directory (and its descendants) as
// write-once-read-many. A non-nil Worm map — even an empty one — // write-once-read-many. A non-nil Worm list — even an empty one —
// puts the path into a WORM zone with these effects, applied AFTER // puts the path into a WORM zone with these effects, applied AFTER
// the normal cascade ACL and BEFORE any admin escape hatch: // the normal cascade ACL and BEFORE any admin escape hatch:
// //
// - write (w) and delete (d) are stripped for everyone // - write (w) and delete (d) are stripped for everyone
// - create (c) is stripped for everyone EXCEPT principals named // - create (c) is stripped for everyone EXCEPT the principals
// in the Worm map with a verb string containing 'c' // listed here — they get read + write-once-create ("cr")
// - read (r) survives if the normal cascade ACL granted it, OR // - read (r) for non-listed principals is whatever the normal
// if the Worm map grants it ('r' in the principal's verb // cascade ACL granted (the WORM list does not itself confer
// string) — so a document controller who isn't in the project // read to outsiders, only to its own members)
// ACL can still be granted read+create here
// //
// Map shape mirrors acl.permissions: keys are email-glob patterns // Each entry is an email-glob pattern (or @role:<name> / a bare
// (or @role:<name>), values are verb strings restricted to the // role name). An empty list ([]) is a WORM zone with no
// subset {r, c}. An empty map ({}) is a WORM zone with no // create-capable principals — the embedded baseline ships this
// create-capable principals — useful as the embedded baseline, // on received/ and issued/ with the `document_controller` role
// which a deployment overrides by placing a .zddc with // named but member-empty, so a deployment enables filing simply
// `worm: {"doc-control@example.com": cr}` at the WORM folder (or // by populating that role. Worm lists UNION across the cascade —
// deeper). Worm grants UNION across the cascade — deeper .zddc // a deeper .zddc adds more controllers.
// can add more controllers.
// //
// Admins (root or subtree) bypass the WORM mask entirely; the // Admins (root or subtree) bypass the WORM constraint entirely;
// handler does the IsAdmin / IsSubtreeAdmin check before invoking // the handler does the IsAdmin / IsSubtreeAdmin check before
// the policy evaluator. WORM is a normal-user constraint, not an // invoking the policy evaluator. WORM is a normal-user
// absolute one — mis-filed documents still need a human escape. // constraint, not an absolute one — mis-filed documents still
Worm map[string]string `yaml:"worm,omitempty" json:"worm,omitempty"` // need a human escape.
Worm []string `yaml:"worm,omitempty" json:"worm,omitempty"`
// AvailableTools restricts which tools the server will auto-serve // AvailableTools restricts which tools the server will auto-serve
// at this directory and its descendants. The effective list is the // at this directory and its descendants. The effective list is the

View file

@ -248,32 +248,22 @@ func ValidateFile(zf ZddcFile) []FieldError {
}) })
} }
} }
// worm: keys are email-glob patterns (or @role:name); values are // worm: is a list of principal patterns (email-globs, @role:name,
// verb strings restricted to {r, c} — write/delete/admin are // or bare role names) that get write-once-create inside the WORM
// meaningless inside a WORM zone and rejecting them at write time // zone. Validate each as an email-glob unless it's a role
// avoids a silently-ineffective entry. // reference (role names are validated by the role machinery).
for principal, verbStr := range zf.Worm { for i, principal := range zf.Worm {
fld := fmt.Sprintf("worm.%s", principal) if strings.HasPrefix(principal, "@role:") {
// @role: prefixes are validated by the role machinery; only continue // role refs validated elsewhere
// check the bare-email-glob form here.
if !strings.HasPrefix(principal, "@role:") {
if err := ValidatePattern(principal); err != nil {
errs = append(errs, FieldError{Field: fld, Message: err.Error()})
continue
}
} }
v, ok := ParseVerbSet(verbStr) // A bare name with no "@" could be a role name; ValidatePattern
if !ok { // accepts it (no @, no whitespace), and MatchesPrincipal
// resolves it as a role if one is defined. So this only
// rejects genuinely malformed entries (whitespace, double @).
if err := ValidatePattern(principal); err != nil {
errs = append(errs, FieldError{ errs = append(errs, FieldError{
Field: fld, Field: fmt.Sprintf("worm[%d]", i),
Message: fmt.Sprintf("invalid verb string %q (allowed: r, c)", verbStr), Message: err.Error(),
})
continue
}
if v&^VerbsRC != 0 {
errs = append(errs, FieldError{
Field: fld,
Message: fmt.Sprintf("verb string %q includes w/d/a — only r and c are valid inside a WORM zone", verbStr),
}) })
} }
} }

View file

@ -79,10 +79,11 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.DropTarget = top.DropTarget out.DropTarget = top.DropTarget
} }
// Worm: presence (non-nil, even empty) marks the WORM zone. // Worm: presence (non-nil, even empty) marks the WORM zone.
// Merge per-key (top wins on clash); preserve a non-nil empty // Concat-dedupe across levels (a deeper .zddc adds controllers);
// map so `worm: {}` survives the overlay. // preserve a non-nil empty slice so `worm: []` survives the
// overlay.
if top.Worm != nil { if top.Worm != nil {
out.Worm = mergeStringMapPreserveEmpty(out.Worm, top.Worm) out.Worm = mergeStringSlicePreserveEmpty(out.Worm, top.Worm)
} }
if top.Virtual != nil { if top.Virtual != nil {
out.Virtual = top.Virtual out.Virtual = top.Virtual
@ -141,17 +142,26 @@ func mergeStringMap(base, top map[string]string) map[string]string {
return out return out
} }
// mergeStringMapPreserveEmpty is mergeStringMap but always returns a // mergeStringSlicePreserveEmpty is mergeStringSlice but always returns
// non-nil result when top is non-nil — so an empty `worm: {}` in a // a non-nil result when top is non-nil — so an empty `worm: []` in a
// .zddc still marks the WORM zone after the overlay. Caller is // .zddc still marks the WORM zone after the overlay. Caller is
// expected to only invoke this when top != nil. // expected to only invoke this when top != nil.
func mergeStringMapPreserveEmpty(base, top map[string]string) map[string]string { func mergeStringSlicePreserveEmpty(base, top []string) []string {
out := make(map[string]string, len(base)+len(top)) seen := make(map[string]struct{}, len(base)+len(top))
for k, v := range base { out := make([]string, 0, len(base)+len(top))
out[k] = v for _, v := range base {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
} }
for k, v := range top { for _, v := range top {
out[k] = v if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
} }
return out return out
} }

View file

@ -31,23 +31,15 @@ package zddc
// grant. Admins are excluded upstream (handler's IsAdmin bypass). // grant. Admins are excluded upstream (handler's IsAdmin bypass).
func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant VerbSet, inWorm bool) { func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant VerbSet, inWorm bool) {
for i := 0; i < len(chain.Levels); i++ { for i := 0; i < len(chain.Levels); i++ {
wm := chain.Levels[i].Worm wl := chain.Levels[i].Worm
if wm == nil { if wl == nil {
continue continue
} }
inWorm = true inWorm = true
for principal, verbStr := range wm { for _, principal := range wl {
if !MatchesPrincipal(principal, email, chain, i, mode) { if MatchesPrincipal(principal, email, chain, i, mode) {
continue grant |= VerbsRC // listed controllers get read + write-once-create
} }
v, ok := ParseVerbSet(verbStr)
if !ok {
// Malformed verb string in operator YAML — ignore the
// entry rather than crash; ValidateFile flags it at
// write time so this shouldn't reach a healthy server.
continue
}
grant |= v
} }
} }
// The embedded baseline could in principle carry a top-level // The embedded baseline could in principle carry a top-level
@ -55,17 +47,14 @@ func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant Ver
// fold it in for completeness. // fold it in for completeness.
if chain.Embedded.Worm != nil { if chain.Embedded.Worm != nil {
inWorm = true inWorm = true
for principal, verbStr := range chain.Embedded.Worm { for _, principal := range chain.Embedded.Worm {
if !MatchesPattern(principal, email) { if MatchesPattern(principal, email) {
continue grant |= VerbsRC
}
if v, ok := ParseVerbSet(verbStr); ok {
grant |= v
} }
} }
} }
if !inWorm { if !inWorm {
return 0, false return 0, false
} }
return grant & VerbsRC, true return grant, true
} }

View file

@ -7,7 +7,7 @@ import (
) )
// TestWormZoneGrant_EmbeddedConvention — archive/<party>/received and // TestWormZoneGrant_EmbeddedConvention — archive/<party>/received and
// issued carry `worm: {}` in defaults.zddc.yaml, so any path under // issued carry `worm: []` in defaults.zddc.yaml, so any path under
// those folders is a WORM zone (inWorm=true) with no create-capable // those folders is a WORM zone (inWorm=true) with no create-capable
// principals (grant=0). Other paths are not WORM zones. // principals (grant=0). Other paths are not WORM zones.
func TestWormZoneGrant_EmbeddedConvention(t *testing.T) { func TestWormZoneGrant_EmbeddedConvention(t *testing.T) {
@ -43,8 +43,8 @@ func TestWormZoneGrant_EmbeddedConvention(t *testing.T) {
// TestWormZoneGrant_OperatorGrantsController — a deployment grants a // TestWormZoneGrant_OperatorGrantsController — a deployment grants a
// document controller create-once by placing a .zddc with a `worm:` // document controller create-once by placing a .zddc with a `worm:`
// entry at (or below) the WORM folder. That principal then gets // entry naming them at (or below) the WORM folder. That principal then
// {r, c} from WormZoneGrant; everyone else still gets 0. // gets {r, c} from WormZoneGrant; everyone else still gets 0.
func TestWormZoneGrant_OperatorGrantsController(t *testing.T) { func TestWormZoneGrant_OperatorGrantsController(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -52,13 +52,12 @@ func TestWormZoneGrant_OperatorGrantsController(t *testing.T) {
if err := os.MkdirAll(issuedDir, 0o755); err != nil { if err := os.MkdirAll(issuedDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
writeZddc(t, issuedDir, "worm:\n \"doc-control@example.com\": cr\n") writeZddc(t, issuedDir, "worm:\n - doc-control@example.com\n")
chain, err := EffectivePolicy(root, issuedDir) chain, err := EffectivePolicy(root, issuedDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Controller gets {r, c}.
g, inWorm := WormZoneGrant(chain, "doc-control@example.com", ModeDelegated) g, inWorm := WormZoneGrant(chain, "doc-control@example.com", ModeDelegated)
if !inWorm { if !inWorm {
t.Fatalf("inWorm = false, want true") t.Fatalf("inWorm = false, want true")
@ -66,29 +65,27 @@ func TestWormZoneGrant_OperatorGrantsController(t *testing.T) {
if g != VerbsRC { if g != VerbsRC {
t.Errorf("controller grant = %v, want rc", g) t.Errorf("controller grant = %v, want rc", g)
} }
// Someone else gets nothing from the worm map.
g2, _ := WormZoneGrant(chain, "rando@example.com", ModeDelegated) g2, _ := WormZoneGrant(chain, "rando@example.com", ModeDelegated)
if g2 != 0 { if g2 != 0 {
t.Errorf("non-controller grant = %v, want 0", g2) t.Errorf("non-controller grant = %v, want 0", g2)
} }
} }
// TestWormZoneGrant_MasksWriteDelete — even if a worm: entry tried to // TestWormZoneGrant_GrantIsAlwaysRC — a worm: entry never confers
// grant w/d/a (which ValidateFile rejects, but defense in depth), // more than {r, c} no matter what (the list form can't even express
// WormZoneGrant strips it to {r, c}. // w/d, but verifying the constant the resolver uses).
func TestWormZoneGrant_MasksWriteDelete(t *testing.T) { func TestWormZoneGrant_GrantIsAlwaysRC(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
rec := filepath.Join(root, "Proj", "archive", "Acme", "received") rec := filepath.Join(root, "Proj", "archive", "Acme", "received")
if err := os.MkdirAll(rec, 0o755); err != nil { if err := os.MkdirAll(rec, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Hand-written with rwcda — should be masked to rc by WormZoneGrant. writeZddc(t, rec, "worm:\n - x@example.com\n")
writeZddc(t, rec, "worm:\n \"x@example.com\": rwcda\n")
chain, _ := EffectivePolicy(root, rec) chain, _ := EffectivePolicy(root, rec)
g, _ := WormZoneGrant(chain, "x@example.com", ModeDelegated) g, _ := WormZoneGrant(chain, "x@example.com", ModeDelegated)
if g != VerbsRC { if g != VerbsRC {
t.Errorf("grant = %v (%s), want rc — w/d/a must be masked", g, g.String()) t.Errorf("grant = %v (%s), want rc", g, g.String())
} }
} }
@ -104,9 +101,8 @@ func TestWormZoneGrant_GrantsUnionAcrossCascade(t *testing.T) {
if err := os.MkdirAll(rec, 0o755); err != nil { if err := os.MkdirAll(rec, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Party-level worm grant for alice; received-level worm grant for bob. writeZddc(t, party, "worm:\n - alice@example.com\n")
writeZddc(t, party, "worm:\n \"alice@example.com\": cr\n") writeZddc(t, rec, "worm:\n - bob@example.com\n")
writeZddc(t, rec, "worm:\n \"bob@example.com\": c\n")
chain, _ := EffectivePolicy(root, rec) chain, _ := EffectivePolicy(root, rec)
ga, inA := WormZoneGrant(chain, "alice@example.com", ModeDelegated) ga, inA := WormZoneGrant(chain, "alice@example.com", ModeDelegated)
@ -114,7 +110,7 @@ func TestWormZoneGrant_GrantsUnionAcrossCascade(t *testing.T) {
t.Errorf("alice grant = %v inWorm=%v, want rc/true", ga, inA) t.Errorf("alice grant = %v inWorm=%v, want rc/true", ga, inA)
} }
gb, _ := WormZoneGrant(chain, "bob@example.com", ModeDelegated) gb, _ := WormZoneGrant(chain, "bob@example.com", ModeDelegated)
if gb&VerbC == 0 { if gb != VerbsRC {
t.Errorf("bob grant = %v, want at least c", gb) t.Errorf("bob grant = %v, want rc", gb)
} }
} }