feat(zddc-server): per-user-home .zddc is fenced (inherit: false)

When a user first writes to <project>/working/<email>/, 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
<project>/working/<email>/.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/<party>/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/<email>/ with inherit: false.
  - TestEnsureCanonicalAncestors_StagingChildNotFenced new — staging
    children stay plain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 22:34:11 -05:00
parent 2aa29d1ec4
commit 0959d57dc2
3 changed files with 108 additions and 23 deletions

View file

@ -122,6 +122,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
type created struct { type created struct {
absPath string absPath string
autoOwn bool autoOwn bool
fenced bool
} }
var freshlyCreated []created var freshlyCreated []created
@ -204,25 +205,35 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return target, err return target, err
} }
// Determine if this newly-created ancestor is an auto-own position. // Determine if this newly-created ancestor is an auto-own position
// 'i' is the index into parentSegs which corresponds to depth i+1 // and whether it should be fenced (inherit: false). 'i' is the
// from fsRoot (parentSegs[0] is the project segment). // index into parentSegs (parentSegs[0] is the project segment).
autoOwn, fenced := autoOwnDepthMatch(parentSegs, i)
freshlyCreated = append(freshlyCreated, created{ freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar, absPath: pathSoFar,
autoOwn: isAutoOwnDepthMatch(parentSegs, i), autoOwn: autoOwn,
fenced: fenced,
}) })
} }
// Seed auto-own .zddc on the canonical positions that were freshly // Seed auto-own .zddc on the canonical positions that were freshly
// created. Skip if no principal email is available (anonymous or // 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 != "" { if principalEmail != "" {
for _, c := range freshlyCreated { for _, c := range freshlyCreated {
if !c.autoOwn { if !c.autoOwn {
continue continue
} }
if err := WriteAutoOwnZddc(c.absPath, principalEmail); err != nil { var werr error
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, err) 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 return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
} }
// isAutoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical // autoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical
// auto-own depth. parentSegs is the slash-relative path from project root // auto-own depth, and whether the auto-own .zddc should fence ancestor
// onward (so parentSegs[0] is the project segment). // cascade (inherit: false). parentSegs is the slash-relative path from
func isAutoOwnDepthMatch(parentSegs []string, idx int) bool { // project root onward (parentSegs[0] is the project segment).
//
// Per-user home folders at <project>/working/<email>/ 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 { switch idx {
case 1: case 1:
// <project>/working or <project>/staging // <project>/working or <project>/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:
// <project>/working/<segment> — 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: case 3:
// <project>/archive/<party>/incoming // <project>/archive/<party>/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
} }

View file

@ -33,13 +33,42 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
t.Errorf("created_by missing: %s", body) t.Errorf("created_by missing: %s", body)
} }
// alice@x.com/ subfolder also exists, no auto-own (it's not a canonical // alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
// position — it's a regular subdir under working/). // 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 { if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
t.Errorf("subfolder not created: %v", err) t.Errorf("subfolder not created: %v", err)
} }
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")); !os.IsNotExist(err) { homeData, err := os.ReadFile(homeZddc)
t.Errorf("subfolder should NOT have auto-own .zddc; got err=%v", err) 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/<folder>/ 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/<folder>/.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)
} }
} }

View file

@ -94,10 +94,31 @@ func IsProjectRootFolder(dirPath string) bool {
// (round-trip guaranteed) and writes via zddc.WriteFile (which // (round-trip guaranteed) and writes via zddc.WriteFile (which
// performs an atomic temp-write + rename via zddc.WriteAtomic). // performs an atomic temp-write + rename via zddc.WriteAtomic).
func WriteAutoOwnZddc(dir, principalEmail string) error { func WriteAutoOwnZddc(dir, principalEmail string) error {
zf := ZddcFile{ return writeAutoOwn(dir, principalEmail, false)
ACL: ACLRules{ }
// 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"}, Permissions: map[string]string{principalEmail: "rwcda"},
}, }
if fenced {
f := false
rules.Inherit = &f
}
zf := ZddcFile{
ACL: rules,
CreatedBy: principalEmail, CreatedBy: principalEmail,
} }
return WriteFile(dir, zf) return WriteFile(dir, zf)