From 2de2fdf92c0ef49812d5ded38c655262ca060478 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 09:40:15 -0500 Subject: [PATCH] refactor(zddc): worm: is a list of principals, not a {principal: verbs} map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- zddc/internal/handler/fileapi_test.go | 16 +++++------ zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/defaults.zddc.yaml | 35 ++++++++++++------------ zddc/internal/zddc/file.go | 39 +++++++++++++-------------- zddc/internal/zddc/validate.go | 38 ++++++++++---------------- zddc/internal/zddc/walker.go | 32 ++++++++++++++-------- zddc/internal/zddc/worm.go | 29 +++++++------------- zddc/internal/zddc/worm_test.go | 32 ++++++++++------------ 8 files changed, 102 insertions(+), 121 deletions(-) diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 1fb9b39..8098e6f 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -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()) } - // Operator places an explicit worm: grant at archive/Acme/issued/.zddc - // naming the document-controller role. That principal then gets - // {r, c} inside the WORM zone — the embedded `worm: {}` (no - // controllers) is unioned with this deeper grant. - issuedZ := []byte(`worm: - _doc_controller: cr -`) + // Operator names the document-controller role in the issued/ WORM + // zone. That role's members then get {r, c} there — the embedded + // `worm: []` (no controllers) is unioned with this deeper grant. + issuedZ := []byte("worm:\n - _doc_controller\n") if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { t.Fatalf("write issued .zddc: %v", err) } @@ -605,8 +602,9 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) { func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Place an explicit worm: grant so dc has cr in the issued WORM zone. - issuedZ := []byte("worm:\n _doc_controller: cr\n") + // Name the document-controller role in the issued/ WORM zone so its + // 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 { t.Fatalf("seed issued .zddc: %v", err) } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 2b8b288..7658021 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-12 13:28:43 · 9c7858c-dirty + v0.0.17-alpha · 2026-05-12 14:40:09 · 918f330-dirty
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 03d50ae..d626b34 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -71,34 +71,33 @@ paths: # Browse shows a drag-drop overlay here. drop_target: true # 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 # - create (c) is stripped for everyone EXCEPT the - # principals listed below (none, in the baseline) - # - read (r) is whatever the normal cascade ACL granted - # — the WORM key does NOT itself grant read, so a - # deployment that restricts read keeps that - # restriction; one that grants read to the project - # keeps that too - # - admins bypass entirely (handler IsAdmin escape) + # principals listed — they get read + write-once- + # create ("cr") + # - read for non-listed principals is whatever the + # normal cascade ACL granted; the WORM list does not + # itself confer read to outsiders + # - admins (root / subtree) bypass entirely — the + # human escape hatch for mis-filed documents # - # The empty map ({}) means "WORM, no create-capable - # principals". A deployment names its document controller - # by placing a .zddc at received/ (or issued/, or - # archive//, or wherever scopes it right) with: + # The baseline is an empty list: WORM zone, no + # create-capable principals — filing is locked until a + # deployment names a document controller, e.g. # - # worm: - # "doc-control@example.com": cr # read + write-once + # worm: ["doc-control@example.com"] # - # worm: grants UNION across the cascade, so multiple - # controllers (or a deeper-scoped one) compose. + # at received/ (or issued/, or archive//, or + # wherever scopes it right). worm: lists UNION across the + # cascade, so a deeper .zddc adds more controllers. received: default_tool: archive - worm: {} + worm: [] issued: default_tool: archive - worm: {} + worm: [] working: default_tool: mdedit available_tools: [mdedit, classifier] diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index c20563f..79ad466 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -210,32 +210,31 @@ type ZddcFile struct { DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` // 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 // the normal cascade ACL and BEFORE any admin escape hatch: // // - write (w) and delete (d) are stripped for everyone - // - create (c) is stripped for everyone EXCEPT principals named - // in the Worm map with a verb string containing 'c' - // - read (r) survives if the normal cascade ACL granted it, OR - // if the Worm map grants it ('r' in the principal's verb - // string) — so a document controller who isn't in the project - // ACL can still be granted read+create here + // - create (c) is stripped for everyone EXCEPT the principals + // listed here — they get read + write-once-create ("cr") + // - read (r) for non-listed principals is whatever the normal + // cascade ACL granted (the WORM list does not itself confer + // read to outsiders, only to its own members) // - // Map shape mirrors acl.permissions: keys are email-glob patterns - // (or @role:), values are verb strings restricted to the - // subset {r, c}. An empty map ({}) is a WORM zone with no - // create-capable principals — useful as the embedded baseline, - // which a deployment overrides by placing a .zddc with - // `worm: {"doc-control@example.com": cr}` at the WORM folder (or - // deeper). Worm grants UNION across the cascade — deeper .zddc - // can add more controllers. + // Each entry is an email-glob pattern (or @role: / a bare + // role name). An empty list ([]) is a WORM zone with no + // create-capable principals — the embedded baseline ships this + // on received/ and issued/ with the `document_controller` role + // named but member-empty, so a deployment enables filing simply + // by populating that role. Worm lists UNION across the cascade — + // a deeper .zddc adds more controllers. // - // Admins (root or subtree) bypass the WORM mask entirely; the - // handler does the IsAdmin / IsSubtreeAdmin check before invoking - // the policy evaluator. WORM is a normal-user constraint, not an - // absolute one — mis-filed documents still need a human escape. - Worm map[string]string `yaml:"worm,omitempty" json:"worm,omitempty"` + // Admins (root or subtree) bypass the WORM constraint entirely; + // the handler does the IsAdmin / IsSubtreeAdmin check before + // invoking the policy evaluator. WORM is a normal-user + // constraint, not an absolute one — mis-filed documents still + // need a human escape. + Worm []string `yaml:"worm,omitempty" json:"worm,omitempty"` // AvailableTools restricts which tools the server will auto-serve // at this directory and its descendants. The effective list is the diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index b8164fe..60bf81e 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -248,32 +248,22 @@ func ValidateFile(zf ZddcFile) []FieldError { }) } } - // worm: keys are email-glob patterns (or @role:name); values are - // verb strings restricted to {r, c} — write/delete/admin are - // meaningless inside a WORM zone and rejecting them at write time - // avoids a silently-ineffective entry. - for principal, verbStr := range zf.Worm { - fld := fmt.Sprintf("worm.%s", principal) - // @role: prefixes are validated by the role machinery; only - // 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 - } + // worm: is a list of principal patterns (email-globs, @role:name, + // or bare role names) that get write-once-create inside the WORM + // zone. Validate each as an email-glob unless it's a role + // reference (role names are validated by the role machinery). + for i, principal := range zf.Worm { + if strings.HasPrefix(principal, "@role:") { + continue // role refs validated elsewhere } - v, ok := ParseVerbSet(verbStr) - if !ok { + // A bare name with no "@" could be a role name; ValidatePattern + // 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{ - Field: fld, - Message: fmt.Sprintf("invalid verb string %q (allowed: r, c)", verbStr), - }) - 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), + Field: fmt.Sprintf("worm[%d]", i), + Message: err.Error(), }) } } diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 7d629a0..56765e6 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -79,10 +79,11 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { out.DropTarget = top.DropTarget } // Worm: presence (non-nil, even empty) marks the WORM zone. - // Merge per-key (top wins on clash); preserve a non-nil empty - // map so `worm: {}` survives the overlay. + // Concat-dedupe across levels (a deeper .zddc adds controllers); + // preserve a non-nil empty slice so `worm: []` survives the + // overlay. if top.Worm != nil { - out.Worm = mergeStringMapPreserveEmpty(out.Worm, top.Worm) + out.Worm = mergeStringSlicePreserveEmpty(out.Worm, top.Worm) } if top.Virtual != nil { out.Virtual = top.Virtual @@ -141,17 +142,26 @@ func mergeStringMap(base, top map[string]string) map[string]string { return out } -// mergeStringMapPreserveEmpty is mergeStringMap but always returns a -// non-nil result when top is non-nil — so an empty `worm: {}` in a +// mergeStringSlicePreserveEmpty is mergeStringSlice but always returns +// 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 // expected to only invoke this when top != nil. -func mergeStringMapPreserveEmpty(base, top map[string]string) map[string]string { - out := make(map[string]string, len(base)+len(top)) - for k, v := range base { - out[k] = v +func mergeStringSlicePreserveEmpty(base, top []string) []string { + seen := make(map[string]struct{}, len(base)+len(top)) + out := make([]string, 0, len(base)+len(top)) + for _, v := range base { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) } - for k, v := range top { - out[k] = v + for _, v := range top { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) } return out } diff --git a/zddc/internal/zddc/worm.go b/zddc/internal/zddc/worm.go index 40717e7..ed1c9fd 100644 --- a/zddc/internal/zddc/worm.go +++ b/zddc/internal/zddc/worm.go @@ -31,23 +31,15 @@ package zddc // grant. Admins are excluded upstream (handler's IsAdmin bypass). func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant VerbSet, inWorm bool) { for i := 0; i < len(chain.Levels); i++ { - wm := chain.Levels[i].Worm - if wm == nil { + wl := chain.Levels[i].Worm + if wl == nil { continue } inWorm = true - for principal, verbStr := range wm { - if !MatchesPrincipal(principal, email, chain, i, mode) { - continue + for _, principal := range wl { + if MatchesPrincipal(principal, email, chain, i, mode) { + 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 @@ -55,17 +47,14 @@ func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant Ver // fold it in for completeness. if chain.Embedded.Worm != nil { inWorm = true - for principal, verbStr := range chain.Embedded.Worm { - if !MatchesPattern(principal, email) { - continue - } - if v, ok := ParseVerbSet(verbStr); ok { - grant |= v + for _, principal := range chain.Embedded.Worm { + if MatchesPattern(principal, email) { + grant |= VerbsRC } } } if !inWorm { return 0, false } - return grant & VerbsRC, true + return grant, true } diff --git a/zddc/internal/zddc/worm_test.go b/zddc/internal/zddc/worm_test.go index 1ec26a0..9aafdea 100644 --- a/zddc/internal/zddc/worm_test.go +++ b/zddc/internal/zddc/worm_test.go @@ -7,7 +7,7 @@ import ( ) // TestWormZoneGrant_EmbeddedConvention — archive//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 // principals (grant=0). Other paths are not WORM zones. func TestWormZoneGrant_EmbeddedConvention(t *testing.T) { @@ -43,8 +43,8 @@ func TestWormZoneGrant_EmbeddedConvention(t *testing.T) { // TestWormZoneGrant_OperatorGrantsController — a deployment grants a // document controller create-once by placing a .zddc with a `worm:` -// entry at (or below) the WORM folder. That principal then gets -// {r, c} from WormZoneGrant; everyone else still gets 0. +// entry naming them at (or below) the WORM folder. That principal then +// gets {r, c} from WormZoneGrant; everyone else still gets 0. func TestWormZoneGrant_OperatorGrantsController(t *testing.T) { resetCache() root := t.TempDir() @@ -52,13 +52,12 @@ func TestWormZoneGrant_OperatorGrantsController(t *testing.T) { if err := os.MkdirAll(issuedDir, 0o755); err != nil { 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) if err != nil { t.Fatal(err) } - // Controller gets {r, c}. g, inWorm := WormZoneGrant(chain, "doc-control@example.com", ModeDelegated) if !inWorm { t.Fatalf("inWorm = false, want true") @@ -66,29 +65,27 @@ func TestWormZoneGrant_OperatorGrantsController(t *testing.T) { if g != VerbsRC { t.Errorf("controller grant = %v, want rc", g) } - // Someone else gets nothing from the worm map. g2, _ := WormZoneGrant(chain, "rando@example.com", ModeDelegated) if g2 != 0 { t.Errorf("non-controller grant = %v, want 0", g2) } } -// TestWormZoneGrant_MasksWriteDelete — even if a worm: entry tried to -// grant w/d/a (which ValidateFile rejects, but defense in depth), -// WormZoneGrant strips it to {r, c}. -func TestWormZoneGrant_MasksWriteDelete(t *testing.T) { +// TestWormZoneGrant_GrantIsAlwaysRC — a worm: entry never confers +// more than {r, c} no matter what (the list form can't even express +// w/d, but verifying the constant the resolver uses). +func TestWormZoneGrant_GrantIsAlwaysRC(t *testing.T) { resetCache() root := t.TempDir() rec := filepath.Join(root, "Proj", "archive", "Acme", "received") if err := os.MkdirAll(rec, 0o755); err != nil { t.Fatal(err) } - // Hand-written with rwcda — should be masked to rc by WormZoneGrant. - writeZddc(t, rec, "worm:\n \"x@example.com\": rwcda\n") + writeZddc(t, rec, "worm:\n - x@example.com\n") chain, _ := EffectivePolicy(root, rec) g, _ := WormZoneGrant(chain, "x@example.com", ModeDelegated) 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 { t.Fatal(err) } - // Party-level worm grant for alice; received-level worm grant for bob. - writeZddc(t, party, "worm:\n \"alice@example.com\": cr\n") - writeZddc(t, rec, "worm:\n \"bob@example.com\": c\n") + writeZddc(t, party, "worm:\n - alice@example.com\n") + writeZddc(t, rec, "worm:\n - bob@example.com\n") chain, _ := EffectivePolicy(root, rec) 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) } gb, _ := WormZoneGrant(chain, "bob@example.com", ModeDelegated) - if gb&VerbC == 0 { - t.Errorf("bob grant = %v, want at least c", gb) + if gb != VerbsRC { + t.Errorf("bob grant = %v, want rc", gb) } }