diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9438f0..34e8f08 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -684,7 +684,9 @@ There are **no hardcoded folder names** — the canonical project structure is d **Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive//{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working//, staging//, reviewing//}`. Six sibling top-level URLs are **virtual aggregators**, never on disk: - **Row rollups** (tables tool, `default_tool: tables`) — `/ssr`, `/mdl`, `/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity). -- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect. +- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. (A party name fails `ValidPartyName` only if it contains a character outside `[A-Za-z0-9.-]` — the resolver then declines to redirect and the path is treated physically; see `working/` below.) Sharing/bookmarks land on the canonical path after the redirect. + + `working/` is the one folder-nav aggregator that **also materialises on disk**: it doubles as a shared project-level drafting space holding **creator-owned working folders** at `/working//`. The slot dir is instantiated lazily by `EnsureCanonicalAncestors` the first time real content is created beneath it (it stays a plain dir — never auto-owned), and each `/` a user creates gets an *unfenced* auto-own `.zddc` (`history: true` inherits in, so markdown drafts there are versioned). Authorisation splits dir-vs-file at the root: project members may create folders (`project_team: rc` in the defaults), but a **bare file directly at the `working/` root is reserved for the `document_controller`** — regular users work inside a folder; the DC creates files at the root or promotes one up with a MOVE. Enforced in `serveFilePut`/`serveFileMove` via `isProjectWorkingRootFile` + `zddc.IsRoleMemberAt`, independent of the ACL verb (since mkdir and file-PUT both authorise as `ActionCreate`). The earlier per-user `working//` "personal workspace" idea was dropped as more complexity than it earned. `staging`/`reviewing` remain non-materialising — `EnsureCanonicalAncestors` still rejects physical writes under them. Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL. diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index dcd067d..5c6a92f 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -298,9 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { return req.WithContext(handler.WithEmail(req.Context(), email)) } - // PUT a new file via dispatch. + // PUT a new file via dispatch. Files live in a sub-folder under + // working/ (creator-owned); bare files at the working/ root are + // document-controller-only (see TestFileAPI_WorkingRootFileDocControllerOnly). body := []byte("note body") - req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com") + req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com") rec := httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusCreated { @@ -308,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // GET it back. - req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK || rec.Body.String() != string(body) { @@ -316,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // MOVE it. - req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") req.Header.Set("X-ZDDC-Op", "move") - req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md") + req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK { @@ -326,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // DELETE it. - req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNoContent { @@ -1063,4 +1065,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { } }) } - diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 31b376f..1581235 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -392,6 +392,17 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { if !authorizeAction(cfg, w, r, abs, cleanURL, action) { return } + // Files placed DIRECTLY at the project-level virtual working/ root are + // reserved for the document controller. Regular users create folders + // under working/ and work inside them; the DC creates files at the + // root or promotes them up from a folder (see serveFileMove). Files in + // sub-folders and the working/.zddc config are unaffected. + if filepath.Base(abs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, abs) && + !zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(abs), "document_controller", EmailFromContext(r)) { + auditFile(r, "put", cleanURL, http.StatusForbidden, 0, nil) + http.Error(w, "Forbidden — files cannot be created directly in working/; create a folder and work inside it. Only the document controller may place files at the working/ root.", http.StatusForbidden) + return + } if !checkIfMatch(w, r, abs) { return } @@ -655,6 +666,13 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { return } + // Promoting a file to the project-level working/ root is reserved for + // the document controller, same as a direct create there (serveFilePut). + if filepath.Base(dstAbs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, dstAbs) && + !zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(dstAbs), "document_controller", EmailFromContext(r)) { + http.Error(w, "Forbidden — only the document controller may move files to the working/ root.", http.StatusForbidden) + return + } if !checkIfMatch(w, r, srcAbs) { return } @@ -810,6 +828,26 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { // Returns (true, reason) when the request should be 409'd. Returns // (false, "") when the target is at any other depth or carries an // allowed name. +// isProjectWorkingRootFile reports whether abs targets a file sitting +// directly in the project-level virtual working/ aggregator — +// /working/ — as opposed to a file inside a sub-folder +// (/working//, depth 4+) or anywhere else. +// Used to gate file creation/promotion at the working/ root to the +// document controller; everything deeper is ordinary creator-owned +// working space. +func isProjectWorkingRootFile(fsRoot, abs string) bool { + rel, err := filepath.Rel(fsRoot, abs) + if err != nil { + return false + } + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "../") || rel == "." { + return false + } + parts := strings.Split(rel, "/") + return len(parts) == 3 && strings.EqualFold(parts[1], "working") +} + func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { rel, err := filepath.Rel(fsRoot, abs) if err != nil { diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 88d9bb7..b8565c8 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -720,3 +720,104 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { // archive//staging// and working at archive// // working//, the project-level pairing no longer maps cleanly. // Tests for the removed behaviour have been deleted.) + +// workingRootSetup builds a root that grants the team rwcd and names a +// document controller (the standard role ships empty; a deployment +// populates it). Returns the same do() helper shape as fileAPITestSetup. +func workingRootSetup(t *testing.T) (root string, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) { + t.Helper() + root = t.TempDir() + rootZddc := "acl:\n permissions:\n \"*@example.com\": rwcd\n" + + "roles:\n document_controller:\n members: [dc@example.com]\n" + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(rootZddc), 0o644); err != nil { + t.Fatalf("write root .zddc: %v", err) + } + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 1 << 20} + do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, target, bytes.NewReader(body)) + } else { + req = httptest.NewRequest(method, target, nil) + } + for k, v := range headers { + req.Header.Set(k, v) + } + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return root, do +} + +// Files directly at the project-level working/ root are document- +// controller-only; folders (and files inside them) are open to the team +// and become creator-owned. +func TestFileAPI_WorkingRootFileDocControllerOnly(t *testing.T) { + root, do := workingRootSetup(t) + + // Non-DC user: a bare file at the working/ root is forbidden. + if rec := do(http.MethodPut, "/Proj/working/memo.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusForbidden { + t.Errorf("non-DC file at working/ root: want 403, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "memo.md")); !os.IsNotExist(err) { + t.Errorf("forbidden file must not be written") + } + + // Same user CAN create a folder and work inside it; the folder + // becomes creator-owned (unfenced auto-own .zddc). + if rec := do(http.MethodPut, "/Proj/working/drafts/notes.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusCreated { + t.Fatalf("user file inside working folder: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + z, err := os.ReadFile(filepath.Join(root, "Proj", "working", "drafts", ".zddc")) + if err != nil { + t.Fatalf("creator-owned folder auto-own .zddc missing: %v", err) + } + if !strings.Contains(string(z), "alice@example.com: rwcda") { + t.Errorf("creator folder should grant alice rwcda; got: %s", z) + } + + // The document controller CAN place a file at the working/ root. + if rec := do(http.MethodPut, "/Proj/working/memo.md", "dc@example.com", []byte("x"), nil); rec.Code != http.StatusCreated { + t.Errorf("DC file at working/ root: want 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// Promoting a file from a working folder up to the working/ root is +// document-controller-only, mirroring direct creation. +func TestFileAPI_WorkingRootMoveDocControllerOnly(t *testing.T) { + root, do := workingRootSetup(t) + + // alice owns a folder with a file. + if rec := do(http.MethodPut, "/Proj/working/drafts/a.md", "alice@example.com", []byte("body"), nil); rec.Code != http.StatusCreated { + t.Fatalf("seed file: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // alice cannot promote it to the working/ root. + rec := do(http.MethodPost, "/Proj/working/drafts/a.md", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Proj/working/a.md", + }) + if rec.Code != http.StatusForbidden { + t.Errorf("non-DC move to working/ root: want 403, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); !os.IsNotExist(err) { + t.Errorf("forbidden move must not create the destination") + } + + // The document controller can. + rec = do(http.MethodPost, "/Proj/working/drafts/a.md", "dc@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Proj/working/a.md", + }) + if rec.Code != http.StatusOK { + t.Errorf("DC move to working/ root: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); err != nil { + t.Errorf("DC move should land the file at the root: %v", err) + } +} diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 2b12a03..35dac96 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -208,19 +208,36 @@ paths: available_tools: [browse] # Project-level working is BOTH an outstanding-only aggregator of # the per-party archive//working/ slots AND the home for - # each user's personal workspace at /working//. + # shared, creator-owned working folders at /working/ + # /. (The earlier per-user / "personal workspace" + # concept was dropped — too much complexity for too little gain. + # A working folder is just a named drafting space its creator + # owns; nothing keys off the email.) virtual: true - # Edit-history versioning for personal markdown drafts (inherits - # to the / homes below). archive//working/ carries - # its own history: true separately. + # Edit-history versioning for markdown drafts (inherits to the + # folders below). archive//working/ carries its own + # history: true separately. history: true + # Project members may CREATE here (so they can make their own + # working folders) but the file handler additionally restricts + # bare files at this root to the document controller — users put + # files inside a folder they created; the DC promotes/creates + # files at the root. The DC carries rwc so it can do both (and, + # unfenced, that authority cascades into every folder below). + acl: + permissions: + project_team: rc + document_controller: rwc paths: "*": - # Per-user personal workspace: /working//. + # A creator-owned working folder: /working//. # auto_own (NOT auto_own_fenced) makes the creator the owner - # (rwcda + a .zddc they can edit) while leaving the home PUBLIC - # by default — the owner opts into privacy by adding - # inherit:false / restricting permissions in their own .zddc. + # (rwcda + a .zddc they can edit) while leaving the folder + # readable to the rest of the project_team via the unfenced + # cascade above. Other members inherit rc here (read + add + # new files) but lack w/d, so they cannot modify or delete + # the owner's files; the owner narrows this in their own + # .zddc if they want it private. default_tool: browse available_tools: [browse] auto_own: true diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 55ef11f..c7bcede 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -84,13 +84,19 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { // // Canonical positions, relative to fsRoot: // -// - /archive (the only physical project-root canonical; -// working/staging/reviewing/ssr/mdl/rsk at project root are virtual -// aggregators with no on-disk presence — writes targeting them -// must be rejected by the caller's project-root mkdir guard.) +// - /archive (a physical project-root canonical) +// +// - /working (virtual at the slot level, but materialised +// on disk to host creator-owned working folders + document- +// controller files; created as a plain dir, never auto-owned) +// +// staging/reviewing/ssr/mdl/rsk at project root are virtual +// aggregators with no on-disk presence — writes targeting them are +// rejected here and by the caller's project-root mkdir guard. +// // - /archive// where // ∈ {mdl, rsk, incoming, received, issued, -// working, staging, reviewing} +// working, staging, reviewing} // // fsRoot and target must be absolute filesystem paths under the same // volume; target may not yet exist on disk. @@ -110,13 +116,24 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, nil } - // Reject writes targeting top-level virtual aggregators — - // /{ssr,mdl,rsk,working,staging,reviewing}/... — these - // resolve through ResolveVirtualView, not as physical paths. A - // caller writing under them bypassed the virtual resolver. + // Project-root virtual aggregators. ssr/mdl/rsk are pure tables + // rollups and staging/reviewing are synthesised lifecycle windows — + // none materialise on disk; row PUTs / lifecycle writes are rewritten + // to canonical archive// paths by ResolveVirtualView before + // reaching here, so a physical write under one of these means the + // resolver was bypassed. + // + // working/ is the exception: it is virtual at the slot level (the + // listing is synthesised from archive/*/working/), but it is also + // the home for creator-owned working folders at /working/ + // /. The slot dir is instantiated on disk the moment real + // content is created beneath it. Fall through to materialise it + + // the target's ancestors; WHO may write here (users create folders; + // only the document controller places files at the root) is gated in + // the file handler, not here. if len(parts) >= 2 { switch strings.ToLower(parts[1]) { - case "ssr", "mdl", "rsk", "working", "staging", "reviewing": + case "ssr", "mdl", "rsk", "staging", "reviewing": return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1]) } } diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go index 6891be0..1da4e1d 100644 --- a/zddc/internal/zddc/ensure_test.go +++ b/zddc/internal/zddc/ensure_test.go @@ -197,12 +197,12 @@ func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) { } } -// Project-root virtual aggregator names are rejected — a write -// targeting /working/<...> bypasses the virtual resolver -// and must not materialise on disk. +// Project-root virtual aggregator names (except working/) are rejected — +// a write targeting /{staging,reviewing,ssr,mdl,rsk}/<...> +// bypasses the virtual resolver and must not materialise on disk. func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { root := t.TempDir() - for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { + for _, slot := range []string{"staging", "reviewing", "ssr", "mdl", "rsk"} { target := filepath.Join(root, "Proj", slot, "x.md") _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) if err == nil { @@ -214,6 +214,52 @@ func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { } } +// working/ is the exception: it hosts creator-owned working folders, so +// real content beneath it instantiates the (otherwise virtual) slot dir +// on disk. The slot dir itself stays plain (never auto-owned); the +// creator-owned folder under it gets the unfenced auto-own .zddc. +func TestEnsureCanonicalAncestors_WorkingMaterialisesCreatorFolder(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "Proj", "working", "drafts", "notes.md") + if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { + t.Fatalf("ensure: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil { + t.Errorf("working/ not created: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { + t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err) + } + folderZddc := filepath.Join(root, "Proj", "working", "drafts", ".zddc") + data, err := os.ReadFile(folderZddc) + if err != nil { + t.Fatalf("creator folder auto-own .zddc missing: %v", err) + } + if !strings.Contains(string(data), "alice@x.com: rwcda") { + t.Errorf("creator folder auto-own missing creator grant: %s", data) + } + if strings.Contains(string(data), "inherit: false") { + t.Errorf("creator working folder must be UNFENCED (readable by the team); got: %s", data) + } +} + +// A bare file directly at the project-level working/ root still +// materialises the slot dir — the file handler gates WHO may write it, +// not EnsureCanonicalAncestors. +func TestEnsureCanonicalAncestors_WorkingRootFileMaterialisesSlot(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "Proj", "working", "memo.md") + if _, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755); err != nil { + t.Fatalf("ensure: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil { + t.Errorf("working/ not created: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { + t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err) + } +} + func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) { root := t.TempDir() other := t.TempDir() diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index bca0398..23fb2ae 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -159,6 +159,23 @@ func HistoryAt(fsRoot, dirPath string) bool { return chain.EffectiveHistory() } +// IsRoleMemberAt reports whether email is a member of roleName as the +// role is visible (after fences / resets, unioned with the embedded +// defaults) at dirPath's cascade leaf. Returns false for an empty +// email or on cascade error. Used by the file handler to gate the few +// operations reserved for a standard role — e.g. only the +// document_controller may place files directly at the working/ root. +func IsRoleMemberAt(fsRoot, dirPath, roleName, email string) bool { + if email == "" { + return false + } + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil || len(chain.Levels) == 0 { + return false + } + return MatchesPrincipal(roleName, email, chain, len(chain.Levels)-1) +} + // IsDeclaredPath reports whether dirPath is mentioned in the // cascade — either by an on-disk .zddc at that level OR by any // ancestor's paths: tree (including the embedded defaults).