feat(zddc): WORM as a cascade key (worm:), retiring hardcoded path predicates
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/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: 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) <noreply@anthropic.com>
This commit is contained in:
parent
9c7858c60a
commit
918f330a6f
12 changed files with 338 additions and 167 deletions
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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-11 21:32:54 · d909756-dirty</span></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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -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/<party>/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
|
||||
|
|
|
|||
|
|
@ -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/<party>/, 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]
|
||||
|
|
|
|||
|
|
@ -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:<name>), 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,82 +124,15 @@ func IsAutoOwnPath(parentDir, fsRoot string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsWormPath reports whether requestPath crosses an
|
||||
// archive/<party>/received/ or archive/<party>/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/<party>/received
|
||||
// and archive/<party>/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/<party>/(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, "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<party>/ — 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/<party>/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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
zddc/internal/zddc/worm.go
Normal file
71
zddc/internal/zddc/worm.go
Normal file
|
|
@ -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
|
||||
}
|
||||
120
zddc/internal/zddc/worm_test.go
Normal file
120
zddc/internal/zddc/worm_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWormZoneGrant_EmbeddedConvention — archive/<party>/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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue