From 918f330a6fd587144f1373e4d1eaba551bd5c28b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 08:29:11 -0500 Subject: [PATCH] feat(zddc): WORM as a cascade key (worm:), retiring hardcoded path predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORM (write-once-read-many) is no longer a special folder type keyed off the literal names "received"/"issued". It's a cascade key — `worm:` on any directory's .zddc — with the ACL-shaped semantics the user described. Schema: worm: "doc-control@example.com": cr # email-glob or @role:name → verbs ⊆ {r, c} # an empty map ({}) is a WORM zone with no create-capable principals Effect inside a WORM zone (any cascade level declares worm:), applied AFTER the normal cascade ACL and BEFORE the admin escape hatch: - w / d / a stripped for everyone - c survives only via the worm: map - r survives via the normal ACL OR the worm: map (so a document controller who isn't in the project ACL still gets read+create) - worm: grants UNION across the cascade — deeper .zddc can name more controllers - admins (root / subtree) bypass entirely — handler does the IsAdmin check before the policy evaluator defaults.zddc.yaml: archive//received and archive//issued carry `worm: {}` (WORM zone, no controllers — the deployment names its document controller by adding a deeper .zddc with `worm: {: cr}`). The canonical convention is unchanged; the difference is an operator can now mark any directory WORM, or rename received/issued, without a code change. Removed (hardcoded path predicates, superseded by the cascade walk): zddc.IsWormPath zddc.WormFolderLevelIndex zddc.splitPathSegments (only IsWormPath used it) Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC. New: zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone) Walks the chain for worm: declarations; unions the principal's grants masked to {r, c}. policy.InternalDecider.Allow: WORM block rewritten to consult WormZoneGrant instead of IsWormPath/WormFolderLevelIndex. ValidateFile: worm: keys validated as email-glob (or @role:name); values validated as verb strings ⊆ {r, c}. Tests: - new worm_test.go covers the embedded convention, operator-granted controller, w/d masking, cross-cascade union. - special_test.go's TestIsWormPath / TestWormFolderLevelIndex retired; TestWormMaskStripsWDA kept. - fileapi_test.go's WORM tests updated: the doc-controller grant is now `worm: { _doc_controller: cr }` at issued/.zddc, not `acl.permissions: { _doc_controller: cr }`. - federal-parity and admin-bypass tests unchanged — the WORM mask still strips w/d/a and admins still bypass. All Go tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/fileapi_test.go | 21 ++--- zddc/internal/handler/tables.html | 2 +- zddc/internal/policy/policy.go | 31 +++---- zddc/internal/zddc/defaults.zddc.yaml | 27 +++++- zddc/internal/zddc/file.go | 28 ++++++ zddc/internal/zddc/lookups.go | 3 + zddc/internal/zddc/special.go | 83 ++---------------- zddc/internal/zddc/special_test.go | 69 ++------------- zddc/internal/zddc/validate.go | 29 +++++++ zddc/internal/zddc/walker.go | 21 +++++ zddc/internal/zddc/worm.go | 71 +++++++++++++++ zddc/internal/zddc/worm_test.go | 120 ++++++++++++++++++++++++++ 12 files changed, 338 insertions(+), 167 deletions(-) create mode 100644 zddc/internal/zddc/worm.go create mode 100644 zddc/internal/zddc/worm_test.go diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index da5971b..1fb9b39 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -511,11 +511,12 @@ 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 grant at archive/Acme/issued/.zddc. - // Now dc has cr at-or-below the WORM folder, which survives the mask. - issuedZ := []byte(`acl: - permissions: - _doc_controller: cr + // 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 `) if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { t.Fatalf("write issued .zddc: %v", err) @@ -604,16 +605,16 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) { func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Place an explicit grant so dc has cr at the issued level. - issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n") + // Place an explicit worm: grant so dc has cr in the issued WORM zone. + issuedZ := []byte("worm:\n _doc_controller: cr\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) } zddc.InvalidateCache(root) - // Doc controller mkdir under issued — should succeed (cr survives mask) - // but should NOT auto-write an ownership .zddc (issued is excluded - // from auto-own). + // Doc controller mkdir under issued — should succeed (cr survives the + // WORM mask) but should NOT auto-write an ownership .zddc (issued is + // not declared auto_own in the cascade). rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 0c45836..2b8b288 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-11 21:32:54 · d909756-dirty + v0.0.17-alpha · 2026-05-12 13:28:43 · 9c7858c-dirty
diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 6e08b14..4fab3b3 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -219,22 +219,23 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro verb := actionVerb(input.Action) email := input.User.Email - // WORM split: in Issued/Received, ancestor grants are read-only; - // only an explicit .zddc placed at-or-below the WORM folder can - // restore `c` (write-once) for principals it names. Admins are - // excluded from this code path by callers (handler package does - // the IsAdmin / IsSubtreeAdmin bypass before invoking Allow). + // WORM zone: a directory whose cascade declares `worm:` (see + // defaults.zddc.yaml — archive//received and issued carry + // `worm: {}`) is write-locked. Inside it, the effective verbs + // for a non-admin principal are: // - // EffectiveVerbsRange (rather than slicing chain.Levels) keeps the - // FULL chain visible to role-membership lookups so an ancestor's - // role definition still applies inside the sub-range walk. - if zddc.IsWormPath(input.Path) { - wormIdx := zddc.WormFolderLevelIndex(input.Path, len(chain.Levels)) - if wormIdx >= 0 { - grantAbove := zddc.EffectiveVerbsRange(chain, 0, wormIdx, email, d.Mode) & zddc.VerbR - grantBelow := zddc.EffectiveVerbsRange(chain, wormIdx, len(chain.Levels), email, d.Mode) & zddc.VerbsRC - return (grantAbove | grantBelow).Has(verb), nil - } + // (normal cascade grant & VerbR) | (worm: grant & VerbsRC) + // + // so write/delete/admin are always stripped, create survives only + // via the worm: map (the deployment names its document + // controller there), and read survives via either the normal ACL + // or the worm: map. Admins are excluded from this code path by + // callers — the handler does the IsAdmin / IsSubtreeAdmin bypass + // before invoking Allow — so a mis-filed document still has a + // human escape hatch. + if wormGrant, inWorm := zddc.WormZoneGrant(chain, email, d.Mode); inWorm { + normalRead := zddc.EffectiveVerbs(chain, email, d.Mode) & zddc.VerbR + return (normalRead | (wormGrant & zddc.VerbsRC)).Has(verb), nil } return zddc.AllowedAction(chain, email, verb, d.Mode), nil diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index a6f38c9..03d50ae 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -70,12 +70,35 @@ paths: auto_own: true # 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: + # + # - 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) + # + # 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: + # + # worm: + # "doc-control@example.com": cr # read + write-once + # + # worm: grants UNION across the cascade, so multiple + # controllers (or a deeper-scoped one) compose. received: default_tool: archive - # received/ is WORM — express as ACL elsewhere; the - # default convention is simply "no auto_own here". + worm: {} issued: default_tool: archive + worm: {} working: default_tool: mdedit available_tools: [mdedit, classifier] diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index ae27849..c20563f 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -209,6 +209,34 @@ type ZddcFile struct { // not its descendants. Defaults (nil): no drop zone. 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 — + // 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 + // + // 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. + // + // 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"` + // AvailableTools restricts which tools the server will auto-serve // at this directory and its descendants. The effective list is the // concat-dedupe union of all AvailableTools across the cascade diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 5b491cd..1e6fd36 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -229,6 +229,9 @@ func isZeroZddcFile(zf ZddcFile) bool { 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 } diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index 0ded0a8..cfd8307 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -124,82 +124,15 @@ func IsAutoOwnPath(parentDir, fsRoot string) bool { return false } -// IsWormPath reports whether requestPath crosses an -// archive//received/ or archive//issued/ segment chain. -// Pure path-segment check; case-fold on canonical names. -// -// The party segment is unrestricted — any directory under archive/ is -// treated as a party, including the self-folder. requestPath may be a -// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem -// path; only segment names matter. -func IsWormPath(requestPath string) bool { - parts := splitPathSegments(requestPath) - for i := 0; i+2 < len(parts); i++ { - if !strings.EqualFold(parts[i], "archive") { - continue - } - // parts[i+1] is the party name (anything). - if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { - return true - } - } - return false -} +// (IsWormPath / WormFolderLevelIndex retired in the cascade-config +// migration — WORM zones are declared via the `worm:` key on a +// directory's .zddc, resolved by WormZoneGrant in worm.go. +// defaults.zddc.yaml carries `worm: {}` on archive//received +// and archive//issued, so the canonical convention is +// unchanged; the difference is that an operator can now mark any +// directory WORM, or rename received/issued, without a code change.) // WormMask reduces a verb set to the subset that survives the WORM // constraint: the bitwise AND with VerbsRC. Removes w, d, and a. -// -// Callers apply this only when IsWormPath(path) is true AND the -// principal is NOT an admin (root admin or subtree admin) — admins -// are the deliberate escape hatch for mis-filed documents. -// -// The WORM mask is split-aware via WormFolderLevelIndex: grants -// inherited from ancestors above the received/issued folder are -// masked to read only ({r}), while grants at-or-below the WORM -// folder retain {r, c} so an operator can place a .zddc at the -// received/issued folder explicitly granting `_doc_controller: cr`. +// Generic helper kept for callers that need the masking primitive. func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC } - -// WormFolderLevelIndex returns the chain index of the deepest -// archive//(received|issued) segment in requestPath. The chain -// corresponds to the directory tree from root (index 0) to the -// requested directory; level i is the .zddc at path segment depth i. -// -// numLevels is len(chain.Levels); used to clamp results to the chain's -// actual range. URL segment i lives at chain index i+1 (root is chain -// index 0), so the WORM segment at parts[i+2] corresponds to chain -// index i+3. -// -// Returns -1 if no WORM segment is in the request path or the computed -// index is out of range. The returned index satisfies -// 0 <= index < numLevels. -func WormFolderLevelIndex(requestPath string, numLevels int) int { - if numLevels <= 0 { - return -1 - } - parts := splitPathSegments(requestPath) - deepest := -1 - for i := 0; i+2 < len(parts); i++ { - if !strings.EqualFold(parts[i], "archive") { - continue - } - if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { - idx := i + 3 - if idx < numLevels && idx > deepest { - deepest = idx - } - } - } - return deepest -} - -// splitPathSegments returns the slash-separated segments of p with -// empty elements removed. Tolerates leading/trailing slashes and -// mixed separators on Windows (via filepath.ToSlash). -func splitPathSegments(p string) []string { - clean := strings.Trim(filepath.ToSlash(p), "/") - if clean == "" { - return nil - } - return strings.Split(clean, "/") -} diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index 537d913..17ab776 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -46,70 +46,11 @@ func TestIsAutoOwnPath(t *testing.T) { } } -func TestIsWormPath(t *testing.T) { - cases := map[string]bool{ - "": false, - "/": false, - "/Project/archive/ACME/issued": true, - "/Project/archive/ACME/issued/": true, - "/Project/archive/ACME/issued/foo.pdf": true, - "/Project/archive/ACME/received/x": true, - "/Project/archive/ACME/Issued/x": true, // case-fold - "/Project/Archive/ACME/issued/x": true, // case-fold - "/Project/archive/ACME/ISSUED/x": true, // case-fold - - // Per-party MDL/incoming aren't WORM. - "/Project/archive/ACME/incoming/x": false, - "/Project/archive/ACME/mdl/x": false, - - // Bare "issued" or "received" not under archive// — no WORM. - "/Project/issued/x": false, - "/Project/received/x": false, - "/Project/working/issued.md": false, // file basename, not a path segment match - "/Project/working/issued": false, // "working" is not "archive" - - // Self-folder is symmetric (any party name works). - "/Project/archive/Self-Org/issued/x.pdf": true, - - // Nested or deep. - "/multi/Project/archive/Vendor/received/sub/file.pdf": true, - } - for in, want := range cases { - if got := IsWormPath(in); got != want { - t.Errorf("IsWormPath(%q) = %v, want %v", in, got, want) - } - } -} - -func TestWormFolderLevelIndex(t *testing.T) { - // Path /Project/archive/ACME/issued/foo.pdf - // parts: [Project, archive, ACME, issued, foo.pdf] - // issued is segment index 3, chain index 4. - if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 6); got != 4 { - t.Errorf("issued at depth 4: got %d, want 4", got) - } - // Same path, but the chain only has 4 levels (numLevels=4 → idx must be < 4). - if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 4); got != -1 { - t.Errorf("clamp: got %d, want -1", got) - } - // No WORM segment. - if got := WormFolderLevelIndex("/Project/working/foo.md", 5); got != -1 { - t.Errorf("no worm: got %d, want -1", got) - } - // Empty. - if got := WormFolderLevelIndex("", 5); got != -1 { - t.Errorf("empty: got %d, want -1", got) - } - // Nested archive//issued — deepest wins. - // parts: [P, archive, A, received, archive, B, issued, x] - // indices: 0 1 2 3 4 5 6 7 - // outer match: i=1 (archive), parts[3]=received → segment idx 3, chain idx 4 - // inner match: i=4 (archive), parts[6]=issued → segment idx 6, chain idx 7 - // deepest = 7. - if got := WormFolderLevelIndex("/P/archive/A/received/archive/B/issued/x", 12); got != 7 { - t.Errorf("nested: got %d, want 7", got) - } -} +// TestIsWormPath / TestWormFolderLevelIndex retired — WORM zones are +// declared via the `worm:` key now (see worm.go's WormZoneGrant, +// exercised by worm_test.go). The convention (received/issued are +// WORM) lives in defaults.zddc.yaml and is asserted via the cascade +// lookup, not a path-segment predicate. func TestWormMaskStripsWDA(t *testing.T) { rwcda, _ := ParseVerbSet("rwcda") diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index 75132e1..b8164fe 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -248,5 +248,34 @@ 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 + } + } + v, ok := ParseVerbSet(verbStr) + if !ok { + 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), + }) + } + } return errs } diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 120ea86..7d629a0 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -78,6 +78,12 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.DropTarget != nil { 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. + if top.Worm != nil { + out.Worm = mergeStringMapPreserveEmpty(out.Worm, top.Worm) + } if top.Virtual != nil { out.Virtual = top.Virtual } @@ -135,6 +141,21 @@ 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 +// .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 + } + for k, v := range top { + out[k] = v + } + return out +} + func mergeStringSlice(base, top []string) []string { if len(top) == 0 { return base diff --git a/zddc/internal/zddc/worm.go b/zddc/internal/zddc/worm.go new file mode 100644 index 0000000..40717e7 --- /dev/null +++ b/zddc/internal/zddc/worm.go @@ -0,0 +1,71 @@ +package zddc + +// WORM (write-once-read-many) zones are declared in the cascade via +// the `worm:` key on a ZddcFile (see file.go). This file resolves the +// effective WORM grant for a principal walking a policy chain. +// +// Replaces the hardcoded IsWormPath / WormFolderLevelIndex / WormMask +// machinery (which keyed off the literal folder names "received" and +// "issued"). The convention now lives in defaults.zddc.yaml — those +// two folders carry `worm: {}` — and any operator can mark another +// directory WORM by adding `worm:` to its .zddc. + +// WormZoneGrant inspects the policy chain for email. If any level in +// the chain (including paths-derived contributions) declares a `worm:` +// map, the path is inside a WORM zone: inWorm is true and grant is the +// UNION of the principal's verb grants across every Worm map in the +// chain, masked to {r, c}. When no level declares worm:, inWorm is +// false and grant is meaningless (returned as 0). +// +// Caller (the policy evaluator) combines this with the normal cascade +// read grant: +// +// if g, inWorm := WormZoneGrant(chain, email, mode); inWorm { +// effective = (normalCascadeVerbs(chain, email, mode) & VerbR) | +// (g & VerbsRC) +// return effective.Has(requestedVerb) +// } +// +// i.e. inside a WORM zone, w/d/a are always stripped; c survives only +// via the worm: grant; r survives via the normal ACL or the worm: +// 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 { + continue + } + inWorm = true + for principal, verbStr := range wm { + if !MatchesPrincipal(principal, email, chain, i, mode) { + continue + } + 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 + // worm: too (it doesn't today — it's declared via paths:), so + // 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 + } + } + } + if !inWorm { + return 0, false + } + return grant & VerbsRC, true +} diff --git a/zddc/internal/zddc/worm_test.go b/zddc/internal/zddc/worm_test.go new file mode 100644 index 0000000..1ec26a0 --- /dev/null +++ b/zddc/internal/zddc/worm_test.go @@ -0,0 +1,120 @@ +package zddc + +import ( + "os" + "path/filepath" + "testing" +) + +// TestWormZoneGrant_EmbeddedConvention — archive//received and +// 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) { + resetCache() + root := t.TempDir() + + cases := []struct { + path string + wantInWorm bool + }{ + {filepath.Join(root, "Proj", "archive", "Acme", "received"), true}, + {filepath.Join(root, "Proj", "archive", "Acme", "issued"), true}, + {filepath.Join(root, "Proj", "archive", "Acme", "received", "2025-Q1"), true}, // deeper still WORM + {filepath.Join(root, "Proj", "archive", "Acme", "incoming"), false}, + {filepath.Join(root, "Proj", "archive", "Acme", "mdl"), false}, + {filepath.Join(root, "Proj", "working"), false}, + {filepath.Join(root, "Proj", "staging"), false}, + } + for _, tc := range cases { + chain, err := EffectivePolicy(root, tc.path) + if err != nil { + t.Fatalf("EffectivePolicy(%q): %v", tc.path, err) + } + grant, inWorm := WormZoneGrant(chain, "anyone@example.com", ModeDelegated) + if inWorm != tc.wantInWorm { + t.Errorf("WormZoneGrant(%q): inWorm = %v, want %v", tc.path[len(root):], inWorm, tc.wantInWorm) + } + if inWorm && grant != 0 { + t.Errorf("WormZoneGrant(%q): grant = %v, want 0 (embedded baseline names no controllers)", tc.path[len(root):], grant) + } + } +} + +// 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. +func TestWormZoneGrant_OperatorGrantsController(t *testing.T) { + resetCache() + root := t.TempDir() + issuedDir := filepath.Join(root, "Proj", "archive", "Acme", "issued") + if err := os.MkdirAll(issuedDir, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, issuedDir, "worm:\n \"doc-control@example.com\": cr\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") + } + 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) { + 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") + 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()) + } +} + +// TestWormZoneGrant_GrantsUnionAcrossCascade — worm: entries at +// multiple cascade levels compose: a controller named at the party +// level plus one named at the received level both get rc inside +// received/. +func TestWormZoneGrant_GrantsUnionAcrossCascade(t *testing.T) { + resetCache() + root := t.TempDir() + party := filepath.Join(root, "Proj", "archive", "Acme") + rec := filepath.Join(party, "received") + 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") + chain, _ := EffectivePolicy(root, rec) + + ga, inA := WormZoneGrant(chain, "alice@example.com", ModeDelegated) + if !inA || ga != VerbsRC { + 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) + } +}