From 0959d57dc266eb2f72bf43304251bf1e7c2f50a3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 22:34:11 -0500 Subject: [PATCH] feat(zddc-server): per-user-home .zddc is fenced (inherit: false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user first writes to /working//, the auto-own .zddc EnsureCanonicalAncestors seeds at that folder now sets acl.inherit: false in addition to the rwcda grant. This makes each user's working subtree private by default — ancestor cascade grants (e.g. a permissive *: r at the project root) no longer let anyone read everyone else's drafts. Implements the user-stated sandbox model: "no automatic or default permissions other than the user's default folder which is instantiated on first save — users can edit the .zddc files in their subtree to allow access to others." The owner can edit /working//.zddc to add collaborators (or set inherit: true, or list specific email patterns). Mechanics: - new WriteAutoOwnZddcFenced — same shape as WriteAutoOwnZddc plus acl.inherit: false. Existing WriteAutoOwnZddc unchanged. - autoOwnDepthMatch returns (autoOwn, fenced); idx 2 under working/ triggers fenced=true. The other auto-own positions (depth 1: working/staging/, depth 3: archive//incoming/) stay unfenced — those are shared lanes where ancestor admin grants should still apply. - staging/ children stay unfenced because staging folders are date+tracking-named (shared lane), not per-user. Tests: - TestEnsureCanonicalAncestors_LazyCreation now asserts the fenced .zddc exists at working// with inherit: false. - TestEnsureCanonicalAncestors_StagingChildNotFenced new — staging children stay plain. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/zddc/ensure.go | 67 +++++++++++++++++++++++-------- zddc/internal/zddc/ensure_test.go | 37 +++++++++++++++-- zddc/internal/zddc/special.go | 27 +++++++++++-- 3 files changed, 108 insertions(+), 23 deletions(-) diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 6855e33..17353cc 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -120,8 +120,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // Track which ancestor directories we end up creating so we can seed // auto-own .zddc files on the right ones afterwards. type created struct { - absPath string - autoOwn bool + absPath string + autoOwn bool + fenced bool } var freshlyCreated []created @@ -204,25 +205,35 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, err } - // Determine if this newly-created ancestor is an auto-own position. - // 'i' is the index into parentSegs which corresponds to depth i+1 - // from fsRoot (parentSegs[0] is the project segment). + // Determine if this newly-created ancestor is an auto-own position + // and whether it should be fenced (inherit: false). 'i' is the + // index into parentSegs (parentSegs[0] is the project segment). + autoOwn, fenced := autoOwnDepthMatch(parentSegs, i) freshlyCreated = append(freshlyCreated, created{ absPath: pathSoFar, - autoOwn: isAutoOwnDepthMatch(parentSegs, i), + autoOwn: autoOwn, + fenced: fenced, }) } // Seed auto-own .zddc on the canonical positions that were freshly // created. Skip if no principal email is available (anonymous or - // system writes). + // system writes). The fenced variant is used at per-user home + // folders under working/ — private by default; owner can later + // edit the .zddc to add collaborators. if principalEmail != "" { for _, c := range freshlyCreated { if !c.autoOwn { continue } - if err := WriteAutoOwnZddc(c.absPath, principalEmail); err != nil { - return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, err) + var werr error + if c.fenced { + werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail) + } else { + werr = WriteAutoOwnZddc(c.absPath, principalEmail) + } + if werr != nil { + return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr) } } } @@ -230,17 +241,41 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil } -// isAutoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical -// auto-own depth. parentSegs is the slash-relative path from project root -// onward (so parentSegs[0] is the project segment). -func isAutoOwnDepthMatch(parentSegs []string, idx int) bool { +// autoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical +// auto-own depth, and whether the auto-own .zddc should fence ancestor +// cascade (inherit: false). parentSegs is the slash-relative path from +// project root onward (parentSegs[0] is the project segment). +// +// Per-user home folders at /working// are fenced so +// each user's working subtree is private by default; the user can edit +// the file to grant access to others. Other auto-own positions stay +// unfenced — they grant the creator while still allowing ancestor +// cascade to add (e.g.) admin grants from the project root. +func autoOwnDepthMatch(parentSegs []string, idx int) (autoOwn bool, fenced bool) { switch idx { case 1: // /working or /staging - return strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") + if strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") { + return true, false + } + case 2: + // /working/ — per-user home folder. Fenced so + // other users can't read it via ancestor cascade by default. + if strings.EqualFold(parentSegs[1], "working") { + return true, true + } case 3: // /archive//incoming - return strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming") + if strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming") { + return true, false + } } - return false + return false, false +} + +// isAutoOwnDepthMatch is a thin wrapper over autoOwnDepthMatch retained +// for any internal callers that only need the first bool. +func isAutoOwnDepthMatch(parentSegs []string, idx int) bool { + autoOwn, _ := autoOwnDepthMatch(parentSegs, idx) + return autoOwn } diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go index ed88163..3104868 100644 --- a/zddc/internal/zddc/ensure_test.go +++ b/zddc/internal/zddc/ensure_test.go @@ -33,13 +33,42 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { t.Errorf("created_by missing: %s", body) } - // alice@x.com/ subfolder also exists, no auto-own (it's not a canonical - // position — it's a regular subdir under working/). + // alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by + // default so other users can't read alice's drafts via ancestor + // cascade. alice can edit the file later to add collaborators. + homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc") if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil { t.Errorf("subfolder not created: %v", err) } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")); !os.IsNotExist(err) { - t.Errorf("subfolder should NOT have auto-own .zddc; got err=%v", err) + homeData, err := os.ReadFile(homeZddc) + if err != nil { + t.Fatalf("per-user-home auto-own .zddc not written: %v", err) + } + homeBody := string(homeData) + if !strings.Contains(homeBody, "alice@x.com: rwcda") { + t.Errorf("per-user-home grant missing: %s", homeBody) + } + if !strings.Contains(homeBody, "inherit: false") { + t.Errorf("per-user-home .zddc should have inherit: false; got: %s", homeBody) + } +} + +// staging// is NOT fenced — staging is a shared lane (transmittal +// folders are date+tracking-named, not per-user). Only per-user homes +// under working/ get the fence. +func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "Proj", "staging", + "2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf") + if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { + t.Fatalf("ensure: %v", err) + } + // staging//.zddc should not exist (only the parent staging/ + // gets an auto-own; the date-named child is plain). + childZddc := filepath.Join(root, "Proj", "staging", + "2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc") + if _, err := os.Stat(childZddc); !os.IsNotExist(err) { + t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err) } } diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index 92010fb..ad29d1e 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -94,10 +94,31 @@ func IsProjectRootFolder(dirPath string) bool { // (round-trip guaranteed) and writes via zddc.WriteFile (which // performs an atomic temp-write + rename via zddc.WriteAtomic). func WriteAutoOwnZddc(dir, principalEmail string) error { + return writeAutoOwn(dir, principalEmail, false) +} + +// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally +// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at +// per-user home folders under working/ where the convention is "private +// by default; owner edits the file to add collaborators." +// +// Without the fence, an ancestor `*: r` (e.g. a project-root grant for +// authenticated users) would let any user read every other user's +// working subfolder via cascade — defeating the per-user sandbox. +func WriteAutoOwnZddcFenced(dir, principalEmail string) error { + return writeAutoOwn(dir, principalEmail, true) +} + +func writeAutoOwn(dir, principalEmail string, fenced bool) error { + rules := ACLRules{ + Permissions: map[string]string{principalEmail: "rwcda"}, + } + if fenced { + f := false + rules.Inherit = &f + } zf := ZddcFile{ - ACL: ACLRules{ - Permissions: map[string]string{principalEmail: "rwcda"}, - }, + ACL: rules, CreatedBy: principalEmail, } return WriteFile(dir, zf)