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:
parent
2aa29d1ec4
commit
0959d57dc2
3 changed files with 108 additions and 23 deletions
|
|
@ -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
|
// Track which ancestor directories we end up creating so we can seed
|
||||||
// auto-own .zddc files on the right ones afterwards.
|
// auto-own .zddc files on the right ones afterwards.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
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{
|
zf := ZddcFile{
|
||||||
ACL: ACLRules{
|
ACL: rules,
|
||||||
Permissions: map[string]string{principalEmail: "rwcda"},
|
|
||||||
},
|
|
||||||
CreatedBy: principalEmail,
|
CreatedBy: principalEmail,
|
||||||
}
|
}
|
||||||
return WriteFile(dir, zf)
|
return WriteFile(dir, zf)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue