From 3115e388fc34406b14cdd0afa18598a2eb299879 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 5 May 2026 15:58:04 -0500 Subject: [PATCH] feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the binary acl.allow/deny model with five permission verbs (r/w/c/d/a) and first-class roles, and adds an authenticated file API (PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps. File API (zddc/internal/handler/fileapi.go) - PUT → action c - PUT → action w - PUT <.zddc> → action a (CanEditZddc strict-ancestor rule) - DELETE → action d - POST mkdir → action c (auto-writes creator-owned .zddc when the parent is Incoming/Working/Staging) - POST move → action w on src + c on dst, atomic via os.Rename - Optional If-Match for optimistic concurrency, --max-write-bytes cap, audit log emits a structured file_write event per operation. Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go) - acl.permissions: { principal → verb-set } map; principals are email patterns or role names. Empty verb set is an explicit deny. - roles: { name → members } definitions, available at the level they declare and all descendants. Closer-to-leaf shadows ancestor. - Legacy acl.allow/deny still work; they fold into permissions at parse time (allow → "rwcd", deny → ""). - Cascade walks leaf→root; first level with any matching entry wins; the union of matching verb sets at that level decides. - --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an ancestor explicit-deny is absolute (NIST AC-6). Default delegated preserves the existing commercial behavior. Special folders (zddc/internal/zddc/special.go) - Incoming / Working / Staging: mkdir auto-writes a .zddc into the new subdir granting created_by + that email rwcda directly. Same form operators write by hand; creator can edit it later to add others. - Issued / Received: server-enforced WORM split. Cascade grants inherited from above the WORM folder are masked to r only; grants placed at-or-below the WORM folder retain r,c. Operators grant write-once (cr) to the doc controller via an explicit .zddc at the Issued/Received folder. Admins exempt — only escape hatch. Browser polyfill (shared/zddc-source.js) - HttpDirectoryHandle + HttpFileHandle implement the FS Access API surface (values, getFileHandle, createWritable, removeEntry, queryPermission/requestPermission) over zddc-server's listing JSON and file API. Existing tools written against showDirectoryPicker work unchanged. - detectServerRoot() returns { handle, status }: tools auto-load on HTTP, surface a clear "no permission to list" message on 403, and fall back to the welcome screen on 0. - classifier renames take the atomic POST move path on HTTP-backed handles; mdedit and transmittal route reads/writes through the polyfill so prior FS-API code paths cover both modes. Tests - zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover delegated vs strict, role membership / shadowing / legacy fallback, WORM split semantics, verb-set parser round-trip. - zddc/internal/handler/fileapi_test.go now also covers role-based vendor scenarios, WORM blocking vendor & doc controller writes, explicit Issued .zddc unlocking the cr drop-box, admin bypass, auto-ownership on mkdir, and strict-mode lockouts. Docs - ARCHITECTURE.md + zddc/README.md document the verb model, role syntax, special-folder behaviors, cascade-mode flag, and full file API surface. Federal-readiness gap analysis strikes AC-3(7) and AC-6. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 10 + ARCHITECTURE.md | 57 +- classifier/build.sh | 1 + classifier/js/app.js | 78 ++- classifier/js/spreadsheet.js | 49 +- mdedit/build.sh | 1 + mdedit/js/file-system.js | 85 +-- mdedit/js/file-tree.js | 4 +- shared/zddc-source.js | 368 +++++++++++++ transmittal/build.sh | 1 + transmittal/js/files.js | 33 ++ zddc/README.md | 155 ++++-- zddc/cmd/zddc-server/main.go | 20 +- zddc/cmd/zddc-server/main_test.go | 70 +++ zddc/internal/apps/availability.go | 10 +- zddc/internal/config/config.go | 30 ++ zddc/internal/handler/cors.go | 5 +- zddc/internal/handler/fileapi.go | 575 ++++++++++++++++++++ zddc/internal/handler/fileapi_test.go | 666 ++++++++++++++++++++++++ zddc/internal/handler/middleware.go | 7 + zddc/internal/handler/profilehandler.go | 2 + zddc/internal/policy/policy.go | 108 +++- zddc/internal/zddc/acl.go | 177 ++++++- zddc/internal/zddc/cascade_mode.go | 48 ++ zddc/internal/zddc/cascade_mode_test.go | 108 ++++ zddc/internal/zddc/file.go | 71 ++- zddc/internal/zddc/roles.go | 191 +++++++ zddc/internal/zddc/roles_test.go | 124 +++++ zddc/internal/zddc/special.go | 122 +++++ zddc/internal/zddc/special_test.go | 76 +++ 30 files changed, 3098 insertions(+), 154 deletions(-) create mode 100644 shared/zddc-source.js create mode 100644 zddc/internal/handler/fileapi.go create mode 100644 zddc/internal/handler/fileapi_test.go create mode 100644 zddc/internal/zddc/cascade_mode.go create mode 100644 zddc/internal/zddc/cascade_mode_test.go create mode 100644 zddc/internal/zddc/roles.go create mode 100644 zddc/internal/zddc/roles_test.go create mode 100644 zddc/internal/zddc/special.go create mode 100644 zddc/internal/zddc/special_test.go diff --git a/AGENTS.md b/AGENTS.md index caaddb6..3196c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,16 @@ shared/ base.css CSS tokens and primitives included first by every tool's CSS build zddc.js canonical filename/folder/revision parsers, formatters, status validation zddc-filter.js shared ZDDC project/status filter UI module + zddc-source.js HTTP source abstraction — FS Access API polyfill (HttpDirectoryHandle, + HttpFileHandle) backed by zddc-server's listing JSON + file API + (PUT/DELETE/POST). Tools that auto-load the current dir in HTTP mode + call window.zddc.source.detectServerRoot() at init. The probe + returns { handle, status }: status 200 → use handle; 403 → user + lacks `r` on this directory (show "no permission to list" + message); 0 → not http(s) or non-zddc-server. Tools must + handle the 403 case so a permission-locked path doesn't + silently render as an empty welcome screen. + hash.js SHA-256 helpers used by the file API + classifier hashes theme.js light/dark theme switcher help.js shared help dialog module build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfad8c7..6e4e296 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -461,10 +461,12 @@ none of them is load-bearing alone. |---|---|---| | Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate | | Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` | -| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | +| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | +| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` | | Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above | | Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only | -| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `/.zddc.d/logs/access-.log` | +| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `/.zddc.d/logs/access-.log`; writes also emit `file_write` op records | +| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically | ### Commercial vs federal trust model @@ -478,8 +480,8 @@ whether to deploy the system should know which column they're in. | Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP | | Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) | | TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS | -| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) | -| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) | +| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) | +| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) | | Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive | | Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery | | Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification | @@ -488,6 +490,53 @@ whether to deploy the system should know which column they're in. The full bullet list with NIST control references is in [`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis." +### Permission model: roles + verbs + +Five permission verbs gate every read and write: + +| Verb | Allows | +|---|---| +| `r` | read file bytes; list directory | +| `w` | overwrite an existing file; rename existing file | +| `c` | create a new file or directory | +| `d` | delete a file | +| `a` | modify the ACL of this subtree (write `.zddc`) | + +`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny` → `""`), so existing deployments behave identically. + +Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`: + +- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny. +- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant. + +The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask. + +#### Special folders + +Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`): + +- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators. +- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents. + +The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after. + +### File API (authenticated CRUD) + +zddc-server exposes write methods on the same URL space as GET. Each method maps to a specific verb and is gated against the cascade-derived verb set: + +| Method | URL | Headers | Action verb | Status | +|---|---|---|---|---| +| `PUT` | `/` | `If-Match: ""` (optional) | `c` | 201 created | +| `PUT` | `/` | `If-Match: ""` (optional) | `w` | 200 overwritten | +| `PUT` | `//.zddc` | — | `a` | 200/201 | +| `DELETE` | `/` | `If-Match: ""` (optional) | `d` | 204 | +| `POST` | `/` | `X-ZDDC-Op: move` + `X-ZDDC-Destination: /new/path` | `w` (src) + `c` (dst) | 200 | +| `POST` | `//` | `X-ZDDC-Op: mkdir` | `c` | 201 created / 200 idempotent | + +Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs. + +Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, and transmittal auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen. + ### Why the tool-rooted view matters for third-party containment A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool diff --git a/classifier/build.sh b/classifier/build.sh index b1cf2c9..6bd5d95 100644 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -35,6 +35,7 @@ concat_files \ "../shared/vendor/docx-preview.min.js" \ "../shared/zddc.js" \ "../shared/hash.js" \ + "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/preview-lib.js" \ "js/app.js" \ diff --git a/classifier/js/app.js b/classifier/js/app.js index dc33ed7..3538814 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -28,33 +28,85 @@ * Initialize the application */ function init() { + // Cache DOM elements + wire events first so the welcome screen + // (and the HTTP-mode auto-load below) can use them. + cacheDOMElements(); + setupEventListeners(); + + // Browser-compatibility branch: + // HTTP mode (served by zddc-server) — works everywhere; the + // HTTP polyfill stands in for the FS Access API. Auto-load + // the directory the page lives in. + // Local mode (file://) — requires FS Access API for write + // access to the user-picked folder. Show the warning if + // the API is missing. + if (location.protocol === 'http:' || location.protocol === 'https:') { + // Don't disable the picker button — even in HTTP mode the + // user might want to add a local folder. But the auto-load + // below means the welcome screen usually never shows. + (async function () { + try { + var probe = await window.zddc.source.detectServerRoot(); + if (probe.handle) { + await openDirectory(probe.handle); + return; + } + if (probe.status === 403) { + showHttpForbiddenMessage(); + return; + } + } catch (err) { + console.warn('classifier: server-mode auto-load failed:', err); + } + // Server-mode probe inconclusive — fall through to welcome. + if (!checkBrowserCompatibility()) { + showBrowserWarning(); + return; + } + showWelcomeScreen(); + })(); + return; + } - - // Check browser compatibility if (!checkBrowserCompatibility()) { showBrowserWarning(); return; } - - // Cache DOM elements - cacheDOMElements(); - - // Set up event listeners - setupEventListeners(); - - // Show welcome screen showWelcomeScreen(); - - } /** - * Check if browser supports File System Access API + * Check if browser supports File System Access API. Used in local + * (file://) mode only — HTTP mode runs through the HTTP polyfill, + * which has no browser dependency beyond fetch. */ function checkBrowserCompatibility() { return 'showDirectoryPicker' in window; } + /** + * Show a clear "no permission to list" message for HTTP-mode users + * who land on a path their ACL doesn't allow them to list. Distinct + * from the welcome screen so the user understands why the file tree + * is empty rather than wondering if they need to pick a folder. + */ + function showHttpForbiddenMessage() { + var screen = document.getElementById('welcomeScreen'); + if (!screen) return; + screen.classList.remove('hidden'); + var msg = document.createElement('div'); + msg.className = 'classifier-forbidden-message'; + msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;'; + msg.innerHTML = + '

No permission to list this directory

' + + '

Your account does not have read access to this folder. ' + + 'You may still be able to upload files if your role allows it; ' + + 'contact the document controller if you believe this is wrong.

'; + screen.appendChild(msg); + var addBtn = document.getElementById('addDirectoryBtn'); + if (addBtn) addBtn.disabled = true; + } + /** * Show browser compatibility warning */ diff --git a/classifier/js/spreadsheet.js b/classifier/js/spreadsheet.js index db4b00c..4b01f26 100644 --- a/classifier/js/spreadsheet.js +++ b/classifier/js/spreadsheet.js @@ -647,29 +647,40 @@ } } - // Rename by copying to new name and deleting old (more reliable than move) + // Rename. HTTP-backed handles (zddc-server) get the atomic + // POST /op=move path — single round-trip, server-side + // os.Rename, no risk of half-renamed state. Local FS Access + // API handles use copy+remove because the API has no native + // rename verb. const oldFilename = zddc.joinExtension(file.originalFilename, file.extension); - try { - // Get fresh handle for old file - const oldHandle = await file.folderHandle.getFileHandle(oldFilename); - - // Read the file content - const fileData = await oldHandle.getFile(); - - // Create new file with new name - const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true }); - const writable = await newHandle.createWritable(); - await writable.write(fileData); - await writable.close(); - - // Delete old file - await file.folderHandle.removeEntry(oldFilename); - - // Update file handle - file.handle = newHandle; + if (window.zddc.source.isHttpHandle(file.folderHandle)) { + const folderUrl = file.folderHandle.url(); + const folderPath = new URL(folderUrl).pathname; + const srcPath = folderPath + encodeURIComponent(oldFilename); + const dstPath = folderPath + encodeURIComponent(newFilename); + await window.zddc.source.moveFile(srcPath, dstPath); + file.handle = await file.folderHandle.getFileHandle(newFilename); + } else { + // Get fresh handle for old file + const oldHandle = await file.folderHandle.getFileHandle(oldFilename); + // Read the file content + const fileData = await oldHandle.getFile(); + + // Create new file with new name + const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true }); + const writable = await newHandle.createWritable(); + await writable.write(fileData); + await writable.close(); + + // Delete old file + await file.folderHandle.removeEntry(oldFilename); + + // Update file handle + file.handle = newHandle; + } } catch (err) { console.error(`Failed to rename file:`, err); throw err; diff --git a/mdedit/build.sh b/mdedit/build.sh index 4475c12..19528c9 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -39,6 +39,7 @@ concat_files \ # JavaScript files to concatenate in order concat_files \ "../shared/zddc.js" \ + "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/preview-lib.js" \ "js/app.js" \ diff --git a/mdedit/js/file-system.js b/mdedit/js/file-system.js index 0de6094..8fcd575 100644 --- a/mdedit/js/file-system.js +++ b/mdedit/js/file-system.js @@ -574,43 +574,48 @@ async function refreshDirectory() { } /** - * Build a synthetic, read-only "file handle" backed by a URL. - * Implements `getFile()` so the rest of the app (which only needs to read) - * works without changes. Lacks `createWritable()` — saveFile detects this - * and routes to a Save-As download. + * Surface a clear "no permission to list this directory" message in + * the file tree pane when the server returns 403 on the initial + * listing. Distinct from "host doesn't serve JSON" so the user + * understands why the tree is empty. */ -function createServerFileHandle(name, url) { - let cached = null; - return { - kind: 'file', - name, - _serverUrl: url, - _readOnly: true, - async getFile() { - if (cached) return cached; - const resp = await fetch(url, { cache: 'no-cache' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`); - const lastMod = resp.headers.get('Last-Modified'); - const lastModified = lastMod ? Date.parse(lastMod) : Date.now(); - const blob = await resp.blob(); - cached = new File([blob], name, { type: blob.type, lastModified }); - return cached; - }, - }; +function showServerForbiddenMessage() { + const treeEl = document.getElementById('file-tree'); + if (!treeEl) return; + treeEl.innerHTML = + '
' + + 'No permission to list this directory.' + + '

Your account does not have read access here. ' + + 'Contact the document controller if you believe this is wrong.

' + + '
'; } /** - * Build a synthetic directory handle (read-only) backed by a server URL. - * Returned for nested entries so existing code paths that probe for `.handle` - * still work; not currently used for traversal. + * Build a CRUD-capable file handle backed by a URL — uses the shared + * HTTP polyfill from window.zddc.source. The polyfill's getFile() does + * a GET, and createWritable() PUTs bytes back (file API on zddc-server). + * + * Adds `_serverUrl` for legacy code paths that still expect that field. + * Marks `_readOnly: false` so editor.js leaves save buttons enabled. + */ +function createServerFileHandle(name, url) { + const handle = new window.zddc.source.HttpFileHandle(url, name); + handle._serverUrl = url; + handle._readOnly = false; + return handle; +} + +/** + * Build a CRUD-capable directory handle backed by a server URL — uses + * the shared HTTP polyfill. Supports values()/entries(), getFileHandle, + * getDirectoryHandle({create}), and removeEntry() against the server + * file API. _serverUrl/_readOnly are kept for legacy probes. */ function createServerDirectoryHandle(name, url) { - return { - kind: 'directory', - name, - _serverUrl: url, - _readOnly: true, - }; + const handle = new window.zddc.source.HttpDirectoryHandle(url, name); + handle._serverUrl = url; + handle._readOnly = false; + return handle; } /** @@ -686,8 +691,16 @@ async function loadServerDirectory() { // listings (zddc-server / Caddy). On a plain static host the probe fails // and we must leave "Add Local Directory" visible so the user can still // load local files. + // + // 403 means the host is a zddc-server but the user lacks `r` on this + // directory (a "no list" permission posture). Show a clear message so + // the user understands why the tree is empty. try { const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); + if (resp.status === 403) { + showServerForbiddenMessage(); + return; + } if (!resp.ok) return; const items = await resp.json(); if (!Array.isArray(items)) return; @@ -710,13 +723,13 @@ async function loadServerDirectory() { entries: {}, }; - // Surface refresh, hide write-only controls. "Add Local Directory" - // stays visible (de-emphasized via btn--subtle) so the user can - // switch to a local folder at any time. + // Surface refresh. The server now exposes a CRUD file API, so write + // controls (new file, save, delete) stay enabled — the polyfill + // routes their writes through PUT/DELETE/POST. "Add Local Directory" + // is de-emphasized so the user can still load a local folder if they + // want, but server-mode is now the default working mode. const refreshBtn = document.getElementById('refreshHeaderBtn'); if (refreshBtn) refreshBtn.classList.remove('hidden'); - const newFileRootBtn = document.getElementById('new-file-root'); - if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); const addDirBtn = document.getElementById('addDirectoryBtn'); if (addDirBtn) { addDirBtn.classList.remove('btn-primary'); diff --git a/mdedit/js/file-tree.js b/mdedit/js/file-tree.js index 916fe91..c00521e 100644 --- a/mdedit/js/file-tree.js +++ b/mdedit/js/file-tree.js @@ -34,8 +34,8 @@ function createActionButtons(filePath, type) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'tree-actions'; - // Server mode is read-only: no rename, delete, or new-file actions. - if (serverSourceMode) return actionsDiv; + // Server mode now supports full CRUD via the file API — drop the + // legacy short-circuit that hid the rename/delete/new-file actions. if (type === 'directory') { // Directory: + (new file) + ✕ (delete) diff --git a/shared/zddc-source.js b/shared/zddc-source.js new file mode 100644 index 0000000..4cfecc9 --- /dev/null +++ b/shared/zddc-source.js @@ -0,0 +1,368 @@ +// shared/zddc-source.js — source abstraction for tools that handle +// directory trees (classifier, mdedit, transmittal, browse, archive). +// +// Two backends: +// +// 1. Local — wraps a real FileSystemDirectoryHandle from the +// File System Access API. Reads + writes go through the +// FS Access API directly. +// +// 2. HTTP — talks to zddc-server's directory listing JSON +// (Accept: application/json) for reads and the file API +// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a +// polyfill of the FS Access API surface area the tools +// use (kind, name, values(), getFileHandle, getDirectoryHandle, +// removeEntry, getFile, createWritable, queryPermission / +// requestPermission) so existing code works unchanged. +// +// The polyfill makes auto-load possible: when zddc-server serves +// a tool at //.html, the tool detects HTTP mode at +// startup, builds an HttpDirectoryHandle for the tool's containing +// directory, and hands it to the existing openDirectory(handle) +// flow without ever showing the file picker. +// +// Renames inside a tool today are typically done as +// "write new + remove old". With HTTP-backed handles this becomes +// PUT + DELETE — non-atomic. Tools that prefer the atomic server +// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl) +// directly instead of going through the polyfill. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + var FA = window.FileSystemDirectoryHandle || null; + + // ----------------------------------------------------------------- + // HTTP file API helpers + // ----------------------------------------------------------------- + + function joinUrl(base, name, isDir) { + if (!base.endsWith('/')) base = base + '/'; + return base + encodeURIComponent(name) + (isDir ? '/' : ''); + } + + // Server returns directory entries with a trailing "/" on names. + // Strip it for the FS Access API name surface. + function stripSlash(name) { + return name.endsWith('/') ? name.slice(0, -1) : name; + } + + async function httpListing(url) { + var resp = await fetch(url, { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) { + var err = new Error('listing ' + url + ': HTTP ' + resp.status); + err.status = resp.status; + throw err; + } + var data = await resp.json(); + if (!Array.isArray(data)) { + throw new Error('listing ' + url + ': non-array body'); + } + return data; + } + + async function httpExists(url) { + try { + var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' }); + return r.ok; + } catch (_) { + return false; + } + } + + // ----------------------------------------------------------------- + // HttpFileHandle — FileSystemFileHandle polyfill + // ----------------------------------------------------------------- + + function makeFile(blob, name, modTime) { + return new File([blob], name, { + type: blob.type, + lastModified: modTime ? modTime.getTime() : Date.now() + }); + } + + function HttpFileHandle(url, name, size, modTime) { + this.kind = 'file'; + this.name = name; + this._url = url; + this._size = size || 0; + this._modTime = modTime || null; + this._etag = null; + } + HttpFileHandle.prototype.getFile = async function () { + var resp = await fetch(this._url, { credentials: 'same-origin' }); + if (!resp.ok) { + throw new Error('GET ' + this._url + ': ' + resp.status); + } + var etag = resp.headers.get('ETag'); + if (etag) this._etag = etag.replace(/"/g, ''); + var lm = resp.headers.get('Last-Modified'); + var modTime = lm ? new Date(lm) : this._modTime; + var blob = await resp.blob(); + return makeFile(blob, this.name, modTime); + }; + HttpFileHandle.prototype.createWritable = async function () { + var chunks = []; + var handle = this; + return { + async write(data) { + if (data == null) return; + if (typeof data === 'object' && data && 'type' in data && data.type === 'write') { + chunks.push(data.data); + return; + } + if (typeof data === 'object' && data && 'type' in data) { + // seek/truncate not supported by HTTP backend + throw new Error('HttpFileHandle write op not supported: ' + data.type); + } + chunks.push(data); + }, + async close() { + var blob = new Blob(chunks); + var resp = await fetch(handle._url, { + method: 'PUT', + body: blob, + credentials: 'same-origin' + }); + if (!resp.ok) { + var body = ''; + try { body = await resp.text(); } catch (_) { /* ignore */ } + throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body); + } + var et = resp.headers.get('ETag'); + if (et) handle._etag = et.replace(/"/g, ''); + handle._size = blob.size; + }, + async abort() { chunks = []; } + }; + }; + HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; }; + HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; }; + HttpFileHandle.prototype.isHttp = true; + HttpFileHandle.prototype.url = function () { return this._url; }; + + // ----------------------------------------------------------------- + // HttpDirectoryHandle — FileSystemDirectoryHandle polyfill + // ----------------------------------------------------------------- + + function HttpDirectoryHandle(url, name) { + this.kind = 'directory'; + if (!url.endsWith('/')) url = url + '/'; + this._url = url; + this.name = name || guessNameFromUrl(url); + } + function guessNameFromUrl(url) { + var u = url.replace(/\/+$/, ''); + var slash = u.lastIndexOf('/'); + return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u; + } + HttpDirectoryHandle.prototype.values = function () { + var url = this._url; + return (async function* () { + var entries; + try { + entries = await httpListing(url); + } catch (e) { + return; + } + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + var rawName = stripSlash(e.name); + var childUrl = joinUrl(url, rawName, e.is_dir); + if (e.is_dir) { + yield new HttpDirectoryHandle(childUrl, rawName); + } else { + var modTime = e.mod_time ? new Date(e.mod_time) : null; + yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime); + } + } + })(); + }; + HttpDirectoryHandle.prototype.entries = function () { + var iter = this.values(); + return (async function* () { + for (;;) { + var step = await iter.next(); + if (step.done) return; + yield [step.value.name, step.value]; + } + })(); + }; + HttpDirectoryHandle.prototype.keys = function () { + var iter = this.values(); + return (async function* () { + for (;;) { + var step = await iter.next(); + if (step.done) return; + yield step.value.name; + } + })(); + }; + HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) { + opts = opts || {}; + var url = joinUrl(this._url, name, false); + var exists = await httpExists(url); + if (!exists && !opts.create) { + var err = new Error('NotFoundError: ' + name); + err.name = 'NotFoundError'; + throw err; + } + return new HttpFileHandle(url, name, 0, null); + }; + HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) { + opts = opts || {}; + var url = joinUrl(this._url, name, true); + if (opts.create) { + var resp = await fetch(url, { + method: 'POST', + headers: { 'X-ZDDC-Op': 'mkdir' }, + credentials: 'same-origin' + }); + if (!resp.ok && resp.status !== 200 && resp.status !== 201) { + throw new Error('mkdir ' + url + ': ' + resp.status); + } + } + return new HttpDirectoryHandle(url, name); + }; + HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) { + opts = opts || {}; + // Probe listing to discover whether name is a file or directory. + var entries; + try { + entries = await httpListing(this._url); + } catch (e) { + throw new Error('removeEntry probe failed: ' + e.message); + } + var match = null; + for (var i = 0; i < entries.length; i++) { + if (stripSlash(entries[i].name) === name) { + match = entries[i]; + break; + } + } + if (!match) { + var err = new Error('NotFoundError: ' + name); + err.name = 'NotFoundError'; + throw err; + } + if (match.is_dir && !opts.recursive) { + // Server doesn't expose a recursive-delete endpoint yet, + // and FS Access API requires recursive=true to remove a + // non-empty directory anyway. Reject explicitly so the + // caller doesn't silently leave a stale tree behind. + var derr = new Error('Removing directories over HTTP is not supported'); + derr.name = 'InvalidStateError'; + throw derr; + } + var url = joinUrl(this._url, name, match.is_dir); + var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' }); + if (!resp.ok && resp.status !== 204) { + throw new Error('DELETE ' + url + ': ' + resp.status); + } + }; + HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; }; + HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; }; + HttpDirectoryHandle.prototype.isHttp = true; + HttpDirectoryHandle.prototype.url = function () { return this._url; }; + + // ----------------------------------------------------------------- + // Top-level helpers + // ----------------------------------------------------------------- + + // Strip a trailing tool .html (e.g. classifier.html) from a path + // to land on the "directory the tool was opened in". + function pathToDir(pathname) { + if (!pathname) return '/'; + if (pathname.endsWith('/')) return pathname; + var slash = pathname.lastIndexOf('/'); + return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; + } + + // Probe the server-mode root for the current page. Returns: + // + // { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned + // { handle: null, status: 403 } — server reachable but listing forbidden + // { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON + // + // Tools that auto-load on startup distinguish 403 (show "no + // permission to list this directory" message) from 0 (fall back + // to local-mode welcome screen). + // + // Tool init pattern: + // if (location.protocol !== 'file:') { + // const r = await zddc.source.detectServerRoot(); + // if (r.handle) await openDirectory(r.handle); + // else if (r.status === 403) showNoPermissionMessage(); + // else showWelcome(); + // } else { showWelcome(); } + async function detectServerRoot() { + if (typeof location === 'undefined') { + return { handle: null, status: 0 }; + } + if (location.protocol !== 'http:' && location.protocol !== 'https:') { + return { handle: null, status: 0 }; + } + var dirPath = pathToDir(location.pathname); + var url = location.origin + dirPath; + try { + await httpListing(url); + } catch (e) { + if (e && e.status === 403) { + return { handle: null, status: 403 }; + } + return { handle: null, status: 0 }; + } + return { + handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)), + status: 200, + }; + } + + // Atomic file move. Path arguments are absolute URL paths + // (starting with /). Honors the file API's POST /op=move + // contract. Returns the new ETag. + async function moveFile(srcUrlPath, dstUrlPath, opts) { + opts = opts || {}; + var headers = { + 'X-ZDDC-Op': 'move', + 'X-ZDDC-Destination': dstUrlPath + }; + if (opts.ifMatch) headers['If-Match'] = opts.ifMatch; + var resp = await fetch(srcUrlPath, { + method: 'POST', + headers: headers, + credentials: 'same-origin' + }); + if (!resp.ok) { + var body = ''; + try { body = await resp.text(); } catch (_) { /* ignore */ } + throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body); + } + var et = resp.headers.get('ETag'); + return et ? et.replace(/"/g, '') : null; + } + + // Detect at construction time whether a directory handle is the + // HTTP polyfill or a real FS Access API handle. Useful for tools + // that want to take the optimized path (e.g. atomic moveFile) + // when in HTTP mode rather than the FS-API copy+remove fallback. + function isHttpHandle(handle) { + return !!(handle && handle.isHttp === true); + } + + window.zddc.source = { + HttpDirectoryHandle: HttpDirectoryHandle, + HttpFileHandle: HttpFileHandle, + detectServerRoot: detectServerRoot, + moveFile: moveFile, + isHttpHandle: isHttpHandle, + // Lower-level helpers exposed for tools that want to call the + // server directly without going through the polyfill. + httpListing: httpListing, + joinUrl: joinUrl, + stripSlash: stripSlash + }; +})(); diff --git a/transmittal/build.sh b/transmittal/build.sh index 200b2e1..4ed9acb 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -45,6 +45,7 @@ concat_files \ "../shared/vendor/docx-preview.min.js" \ "../shared/zddc.js" \ "../shared/hash.js" \ + "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/preview-lib.js" \ "js/app.js" \ diff --git a/transmittal/js/files.js b/transmittal/js/files.js index 9b3f3c8..7e245f2 100644 --- a/transmittal/js/files.js +++ b/transmittal/js/files.js @@ -1539,4 +1539,37 @@ filesModule.bindActionButtons(); filesModule.setupTableEditing(); }); + + // Auto-load when served by zddc-server: the page lives at + // /<...>/Staging//transmittal.html and that folder IS the + // working transmittal. Build an HTTP polyfill handle for it, + // assign it as the selected directory, and run the same scan + // pipeline the "Add Directory" button does. + // + // A 403 on the listing probe means the user can't list this folder — + // transmittal needs `r` at minimum, so show a clear message rather + // than silently leaving the editor empty. + app.registerInit(async function () { + if (typeof location === 'undefined') { return; } + if (location.protocol !== 'http:' && location.protocol !== 'https:') { return; } + if (app.data.selectedDirHandle) { return; } + try { + var probe = await window.zddc.source.detectServerRoot(); + if (probe.handle) { + app.data.selectedDirHandle = probe.handle; + updateDirectoryIndicator(probe.handle.name); + // Run the same flow as the "Add Directory" button, minus + // the click-event plumbing — selectDirectory will skip the + // picker because selectedDirHandle is already set. + await selectDirectory({ currentTarget: null }); + return; + } + if (probe.status === 403) { + console.warn('[transmittal] no permission to list directory; transmittal needs `r` at minimum'); + updateDirectoryIndicator('— no permission to list this directory —'); + } + } catch (err) { + console.warn('[transmittal] HTTP auto-load failed:', err); + } + }); })(window.transmittalApp); diff --git a/zddc/README.md b/zddc/README.md index d0f6070..591943a 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -159,15 +159,59 @@ asymmetry that bites operators on first contact — read "When the cascade helps when it fights you" below before designing a layout. ```yaml -# Example .zddc file +# Example .zddc — modern schema with verbs and roles +roles: + _company: + members: ["*@mycompany.com"] + _doc_controller: + members: [dc@mycompany.com] acl: - allow: - - "*@mycompany.com" # everyone at mycompany.com - - "contractor@partner.com" # specific external user - deny: - - "intern@mycompany.com" # override: block this specific user + permissions: + _company: r # everyone at mycompany.com gets read + _doc_controller: rwcda # doc controller gets full control + "contractor@partner.com": rw # specific external — read + overwrite + "intern@mycompany.com": "" # explicit deny (empty verb set) + +# Legacy form below still works — equivalent to the new form, with +# allow → "rwcd" and deny → "" entries auto-merged into permissions. +# acl: +# allow: ["*@mycompany.com", "contractor@partner.com"] +# deny: ["intern@mycompany.com"] ``` +### Permission verbs + +Every access decision resolves to a verb set drawn from `r/w/c/d/a`: + +| Verb | Allows | +|---|---| +| `r` | read file bytes; list directory | +| `w` | overwrite an existing file; rename existing file | +| `c` | create a new file or directory | +| `d` | delete a file | +| `a` | modify the ACL of this subtree (write `.zddc`) | + +The verb set is written as concatenated lowercase letters in canonical order — `""` (none / explicit deny), `r`, `cr`, `rwcd`, `rwcda`. Common archetypes: + +- **`r`** — read-only (typical company default). +- **`cr`** — append-only / drop-box (the doc controller in `Issued`/`Received`: can file new documents, cannot overwrite or delete). +- **`rwcd`** — full content control without the right to change the ACL (vendor inside their working subtree). +- **`rwcda`** — full control including the ability to grant access to others (subtree creator; project owner). + +### Roles + +`roles:` defines named principal groups, available at the level they're declared and all descendants: + +```yaml +roles: + vendor_acme: + members: ["*@acme.com"] + _doc_controller: + members: [dc@mycompany.com, alice@mycompany.com] +``` + +Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). + ### Step 1: starter `.zddc` Every install should write a root `.zddc` before exposing the bind address. The @@ -187,26 +231,62 @@ that need them. (See worked examples below.) ### How a request is evaluated -When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along -the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest -level first) looking for a match. The first explicit match wins — either an allow -or a deny. +Each request carries an **action verb** (`r` for `GET`, `w` for `PUT` to an +existing file, `c` for `PUT` to a new file or `mkdir`, `d` for `DELETE`, `a` +for writes to `.zddc`). zddc-server reads every `.zddc` along the chain from +`ZDDC_ROOT` down to the request directory, then walks **leaf → root** looking +for a level whose `acl.permissions` map matches the user. -1. **At the current level**, check deny patterns first. If the email matches any - deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny - beats allow — see anti-patterns below.)* -2. **Same level**, check allow patterns. If the email matches → **allow**, stop - walking. -3. **No match at this level** → walk up to the parent directory's `.zddc` and - repeat. +1. **Admin bypass.** If the email is in the root `admins:` list (root admin) or + any subtree-level `admins:` list on the chain (subtree admin), grant + `rwcda` and skip the cascade entirely. +2. **At each level**, find every `permissions:` entry whose principal matches + the user (direct email pattern, or role membership via `roles:` lookup). + - If any matching entry has the empty verb set `""` → **403 Forbidden**, stop. + - Otherwise, take the **union** of matching verb sets at this level. If the + union is non-empty, the level "wins" — the requested verb is allowed iff + it's present in the union. Stop walking. +3. **No match at this level** → walk up to the parent directory's `.zddc`. 4. **No level matched anywhere in the chain:** - - If no `.zddc` file existed anywhere in the chain (`HasAnyFile=false`) → **allow** (the empty-tree default; see warning above). - - If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny). + - No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default). + - At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny). -The two functions implementing this are `AllowedAtLevel` (within-level: deny first, -then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first -walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is -built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`. +Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the +per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain +itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`). + +#### Cascade mode + +The leaf-overrides-ancestor behavior above is the default — it's the historical +commercial-tenant model where a subtree owner can grant access without +root-admin involvement. Federal deployments needing absolute parent denies +(NIST AC-6) start the server with `--cascade-mode=strict` (or +`ZDDC_CASCADE_MODE=strict`): + +- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny. +- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any + matching explicit-deny; if found, denied (subject to root-admin bypass). + Second pass is the leaf→root grant walk above. An ancestor explicit-deny + cannot be overridden by any leaf grant. + +The mode is logged at startup and surfaced on `/.profile/config`. Subtree +`.zddc` files cannot change the mode — it's a deployment-wide policy. + +#### Special folders + +Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`): + +- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /// X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: ` and `permissions: { : rwcda }`. The grant uses the same direct email-pattern form an operator would write by hand; the creator can edit the `.zddc` later to add collaborators. `created_by` is an audit field — the cascade evaluator does not consult it. +- **`Issued`, `Received`** — *write-once / immutable archive*. When a request path crosses an `Issued` or `Received` segment, the server applies a **WORM split**: cascade grants inherited from ancestors above the WORM folder are masked to `r` only; grants at-or-below the WORM folder retain `r,c`. Anyone with `w`/`d`/`a` from inheritance loses those verbs once they enter the archive. To grant write-once (`cr`) to the doc controller, the operator places an explicit `.zddc` at the `Issued` or `Received` folder: + + ```yaml + # //Issued/.zddc + acl: + permissions: + _doc_controller: cr + ``` + + The mask preserves the `c` from this same-level grant, so the doc controller can file new documents — but they still cannot overwrite, delete, or change the ACL. **Only admins (root or subtree) can mutate filed documents.** The mask is server-enforced and not configurable in v1; operators who want a non-WORM directory must avoid the names `Issued` and `Received`. ### Glob patterns @@ -586,20 +666,21 @@ have to redo the gap analysis from scratch. - **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream proxy. Required: documented reference deployment with PIV/CAC via oauth2-proxy or equivalent. -- **Role-based access control** (NIST AC-3(7)) — current model is per-email - allow/deny + a single root-admin role. Required: roles as first-class - entities, `.zddc` syntax for role grants, identity-source-driven role - assignment. -- **Least-privilege bounding** (NIST AC-6) — *partially complete.* - Leaf-allow-overrides-parent-deny is the cascade's intentional - delegation behavior in commercial mode and is preserved in the - internal Go evaluator. For federal deployments, `--print-rego=federal` - emits a parity-tested Rego policy where parent denies are absolute; - drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still - required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`) - that switches the in-process Go evaluator's semantics without - requiring an OPA sidecar — currently federal-mode is reachable only - via the external-OPA path. +- ~~**Role-based access control** (NIST AC-3(7))~~ — *closed.* Roles are + first-class entities defined under `roles:` in any `.zddc`, available + at the level they're declared and all descendants. `acl.permissions` + grants verb sets (`r`/`w`/`c`/`d`/`a`) per role or per email pattern. + Identity-source-driven role assignment plumbs through unchanged + (the upstream proxy still asserts the email; role membership is + evaluated server-side against the cascade). +- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators + set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to + switch the in-process Go evaluator into the federal posture: any + ancestor explicit-deny is absolute and cannot be overridden by a + leaf grant. The mode is logged at startup and surfaced on + `/.profile/config`. The legacy commercial behavior is preserved as + the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains + available for org-specific Rego on top of this. - **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to authoritative sources (PIV cert subject, IdP-managed identity). Required: documented integration with at least one IdP supporting federal identity diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index f12d214..94b0a9d 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -135,9 +135,10 @@ func main() { // http(s):// or unix:// values send each decision to an external // OPA-compatible server (federal customers, custom Rego policies). deciderCfg := policy.Config{ - URL: cfg.OPAURL, - FailOpen: cfg.OPAFailOpen, - CacheTTL: cfg.OPACacheTTL, + URL: cfg.OPAURL, + FailOpen: cfg.OPAFailOpen, + CacheTTL: cfg.OPACacheTTL, + CascadeMode: cfg.CascadeMode, } // Translate "0" (operator opt-out) to "disable cache" (negative TTL is // the policy package's sentinel for "skip the wrapper"). @@ -152,7 +153,8 @@ func main() { slog.Info("policy decider ready", "mode", policyModeLabel(cfg.OPAURL), "url", cfg.OPAURL, - "cache_ttl", cfg.OPACacheTTL) + "cache_ttl", cfg.OPACacheTTL, + "cascade_mode", cfg.CascadeMode) // Innermost handler: dispatch. var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -487,6 +489,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // File API — authenticated CRUD over the served tree. Catches PUT, + // DELETE, and POST on any non-reserved path. Read methods (GET/HEAD) + // fall through to the static / apps / directory pipeline below. + // Forms and .profile/.archive POSTs are already routed above this + // point so they take precedence. + if handler.IsWriteMethod(r.Method) { + handler.ServeFileAPI(cfg, w, r) + return + } + // Apps resolution for the root landing path: GET / or /index.html with // no real index.html on disk → serve via apps.Serve("landing"). The // other four apps are caught by the "stat fails → app HTML?" branch diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index b583056..a03138a 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -215,6 +215,76 @@ func TestDispatchAppsResolution(t *testing.T) { // import even when we trim test cases later. var _ = apps.DefaultUpstream +// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST +// to the file API rather than to the read pipeline. +func TestDispatchRoutesWritesToFileAPI(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n allow:\n - \"*@example.com\"\n deny: []\n") + mustMkdir(t, filepath.Join(root, "Project-A", "Working")) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{ + Root: root, + IndexPath: ".archive", + EmailHeader: "X-Auth-Request-Email", + MaxWriteBytes: 1 << 20, + } + ring := handler.NewLogRing(10) + + withEmail := func(req *http.Request, email string) *http.Request { + // dispatch reads email from context (ACLMiddleware would normally + // set it), so set it directly here. + return req.WithContext(handler.WithEmail(req.Context(), email)) + } + + // PUT a new file via dispatch. + body := []byte("note body") + req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com") + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // GET it back. + req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com") + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != string(body) { + t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String()) + } + + // MOVE it. + req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com") + req.Header.Set("X-ZDDC-Op", "move") + req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md") + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // DELETE it. + req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com") + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String()) + } + + // Reserved segment guard still applies to writes. + req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com") + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code) + } +} + func mustMkdir(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index 62e8b55..17c4681 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -3,13 +3,19 @@ package apps import ( "path/filepath" "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // Folder name conventions that gate which tools are virtually available // at a given path. The names are case-sensitive; ZDDC convention uses -// the capitalized forms. +// the capitalized forms. The full canonical list lives in +// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls +// the relevant subsets from there to avoid duplication. var ( - folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"} + // Subset of zddc.AutoOwnFolderNames where classifier is virtually + // available (the same three folders that grant mkdir auto-ownership). + folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames folderNamesWorking = []string{"Working"} folderNamesStaging = []string{"Staging"} ) diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 1b6caf5..f25a18a 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -31,6 +31,8 @@ type Config struct { OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed) OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable. AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there. + MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413. + CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6). } // ErrHelpRequested is returned by Load when --help is passed; the caller @@ -91,6 +93,10 @@ func Load(args []string) (Config, error) { "External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.") appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"), "Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.") + maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024), + "Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.") + cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"), + "ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).") accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"), "Tee structured access logs to this file (JSON, size-rotated). "+ "Default: /.zddc.d/logs/access-.log. "+ @@ -148,6 +154,8 @@ func Load(args []string) (Config, error) { OPAFailOpen: *opaFailOpenFlag, OPACacheTTL: *opaCacheTTLFlag, AppsPubKey: *appsPubKeyFlag, + MaxWriteBytes: *maxWriteBytesFlag, + CascadeMode: *cascadeModeFlag, } // Default Root to the current working directory. @@ -216,6 +224,15 @@ func Load(args []string) (Config, error) { return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty") } + switch cfg.CascadeMode { + case "", "delegated": + cfg.CascadeMode = "delegated" + case "strict": + // ok + default: + return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode) + } + // Plain HTTP mode trusts the email header from any client. Only safe // behind an authenticating reverse proxy. Refuse to start when binding // plain HTTP to a non-loopback interface unless the operator has @@ -340,3 +357,16 @@ func parseDurationOrDefault(s string, def time.Duration) time.Duration { } return def } + +// parseInt64OrDefault parses a base-10 int64. Returns def on empty input +// or parse error. +func parseInt64OrDefault(s string, def int64) int64 { + if s == "" { + return def + } + var n int64 + if _, err := fmt.Sscan(s, &n); err == nil { + return n + } + return def +} diff --git a/zddc/internal/handler/cors.go b/zddc/internal/handler/cors.go index 7862a50..e0485a8 100644 --- a/zddc/internal/handler/cors.go +++ b/zddc/internal/handler/cors.go @@ -42,7 +42,10 @@ func CORSMiddleware(cfg config.Config, next http.Handler) http.Handler { if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { h.Set("Access-Control-Allow-Headers", reqHeaders) } - h.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + h.Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, POST, OPTIONS") + // Expose ETag and the file API's source / destination headers + // so cross-origin clients can read them after a write. + h.Set("Access-Control-Expose-Headers", "ETag, X-ZDDC-Source, X-ZDDC-Destination") h.Set("Access-Control-Max-Age", "600") w.WriteHeader(http.StatusNoContent) return diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go new file mode 100644 index 0000000..1888ff8 --- /dev/null +++ b/zddc/internal/handler/fileapi.go @@ -0,0 +1,575 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// File API — authenticated CRUD over the served tree. +// +// PUT / write or overwrite. Body = file bytes (capped +// by cfg.MaxWriteBytes). Auto-creates parent +// directories. Optional If-Match for optimistic +// concurrency. Returns 201 Created (new) or 200 +// OK (overwrite) with ETag. +// DELETE / remove a file. Optional If-Match. Refuses to +// delete directories or hidden paths. +// POST / control verb dispatched by X-ZDDC-Op header: +// move: X-ZDDC-Destination is the new path. +// Atomic os.Rename. Optional If-Match. +// mkdir: create directory at //. Idempotent. +// +// All operations route through the same ACL chain as GET — but with +// policy action="write" so external Rego can split read from write. +// The internal decider treats both identically. +// +// Path posture matches the rest of the dispatch: +// - hidden segments (./_-prefixed) are 404'd +// - the apps cache directory _app is 404'd +// - traversal that escapes Root is 404'd +// +// Audit: every successful write logs a structured `file_write` event +// (op, path, email, status, bytes) at INFO. Failed writes log at WARN. +const ( + headerOp = "X-ZDDC-Op" + headerDestination = "X-ZDDC-Destination" + + opMove = "move" + opMkdir = "mkdir" +) + +// IsWriteMethod reports whether this method is handled by the file API. +// Used by the dispatcher to gate writes through ServeFileAPI before the +// read-path tree of static / app / directory handling. +func IsWriteMethod(method string) bool { + switch method { + case http.MethodPut, http.MethodDelete, http.MethodPost: + return true + } + return false +} + +// ServeFileAPI is the entry point for write methods. The dispatcher +// has already verified the path doesn't contain reserved segments. +// Caller must have already enforced the dot-prefix / _app guards +// (these match dispatch's existing ones, but we re-check defensively). +func ServeFileAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + serveFilePut(cfg, w, r) + case http.MethodDelete: + serveFileDelete(cfg, w, r) + case http.MethodPost: + serveFilePost(cfg, w, r) + default: + w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, POST, OPTIONS") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +// resolveTargetPath validates urlPath, joins it onto cfg.Root, and +// rejects traversal/hidden segments. Returns absolute path + the +// cleaned URL path (with one leading "/"). +func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL string, ok bool, status int, msg string) { + if urlPath == "" || urlPath == "/" { + return "", "", false, http.StatusBadRequest, "empty path" + } + cleanURL = "/" + strings.Trim(urlPath, "/") + if strings.HasSuffix(urlPath, "/") { + cleanURL += "/" + } + + // Reject hidden / reserved segments. Mirrors dispatch's guard, + // applied here too because external callers reach ServeFileAPI + // only via dispatch — but defense in depth costs nothing. + for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") { + if seg == "" { + continue + } + if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { + return "", "", false, http.StatusNotFound, "reserved path segment" + } + } + + rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/")) + abs := filepath.Join(cfg.Root, rel) + if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { + return "", "", false, http.StatusNotFound, "path traversal" + } + return abs, cleanURL, true, 0, "" +} + +// authorizeAction runs the ACL chain for a verb-tagged write to absPath. +// The chain is computed from the closest existing ancestor (so writes +// that create a brand-new file inherit the parent directory's chain). +// Returns allowed=false with the response status already written on deny. +// +// Admin escape hatches: root admins (IsAdmin) and subtree admins +// (IsSubtreeAdmin) get unconditional access — the cascade evaluator +// and the WORM mask do not see their requests at all. This matches +// the existing admin-bypass semantics in /.profile/zddc and is the +// only way to mutate filed documents in Issued/Received. +// +// .zddc writes use the stricter CanEditZddc rule (strict-ancestor +// admin authority) regardless of the action verb, since the file +// being written is itself the source of the authority decision and +// the strict-ancestor rule is the existing defense against +// self-elevation. +func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool { + probe := filepath.Dir(absPath) + for { + info, err := os.Stat(probe) + if err == nil && info.IsDir() { + break + } + if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) { + probe = cfg.Root + break + } + probe = filepath.Dir(probe) + } + + email := EmailFromContext(r) + + // Admin bypass — root and subtree. + if zddc.IsAdmin(cfg.Root, email) { + return true + } + if zddc.IsSubtreeAdmin(cfg.Root, probe, email) { + return true + } + + // .zddc writes: CanEditZddc enforces the strict-ancestor rule that + // prevents a subtree admin from elevating themselves by editing the + // .zddc that grants their authority. Non-admins fall through to the + // regular decider — they will be denied unless an explicit `a` verb + // is granted to a non-admin role at this path, which is unusual. + if filepath.Base(absPath) == ".zddc" { + zddcDir := filepath.Dir(absPath) + if zddc.CanEditZddc(cfg.Root, zddcDir, email) { + return true + } + // Non-admin .zddc writes go through the normal cascade with + // action=admin. Most deployments will have no acl.permissions + // entry granting `a`, so this denies; operators who want + // non-admin .zddc edits can grant `a` explicitly. + } + + chain, err := zddc.EffectivePolicy(cfg.Root, probe) + if err != nil { + slog.Warn("file API ACL chain error", "path", absPath, "err", err) + } + + decider := DeciderFromContext(r) + allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action) + if !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + return true +} + +// readBodyCapped consumes r.Body up to cfg.MaxWriteBytes. Returns 413 +// on overflow. The body is read fully into memory — small / medium files +// are the dominant traffic and atomic write needs the whole payload before +// the rename. Streaming PUTs (chunked uploads, multi-part resumable) +// are out of scope for this iteration. +func readBodyCapped(cfg config.Config, w http.ResponseWriter, r *http.Request) ([]byte, bool) { + limit := cfg.MaxWriteBytes + if limit <= 0 { + limit = 256 * 1024 * 1024 + } + // http.MaxBytesReader writes a 413 itself when the limit is hit + // during read, but its error message is not always recognizable — + // we wrap it to surface a clean status code from the wrapped error. + r.Body = http.MaxBytesReader(w, r.Body, limit) + body, err := io.ReadAll(r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) + return nil, false + } + http.Error(w, "Bad Request — could not read body: "+err.Error(), http.StatusBadRequest) + return nil, false + } + return body, true +} + +// fileETag returns the SHA-256 first-32-hex of bytes — the same scheme +// the static file serve handler uses, so PUT response ETags match what +// a subsequent GET would compute. +func fileETag(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:])[:32] +} + +// fileETagOnDisk returns the ETag of the file at absPath (or "" if it +// doesn't exist). Used to evaluate If-Match on PUT/DELETE/MOVE. +func fileETagOnDisk(absPath string) (string, error) { + body, err := os.ReadFile(absPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + return fileETag(body), nil +} + +// checkIfMatch returns true if the request's If-Match header (when +// present) matches the current ETag for absPath. Empty header always +// passes. Wildcard ("*") passes iff the file exists. On precondition +// failure the response is written as 412 and false is returned. +// +// Special case for PUT: when allowMissing is true and the file doesn't +// exist, the wildcard "*" form fails (per RFC) but a specific ETag is +// treated as a no-current-file hit (412). This distinguishes +// create-new from update-existing semantically. +func checkIfMatch(w http.ResponseWriter, r *http.Request, absPath string) bool { + header := strings.TrimSpace(r.Header.Get("If-Match")) + if header == "" { + return true + } + current, err := fileETagOnDisk(absPath) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return false + } + if header == "*" { + if current == "" { + http.Error(w, "Precondition Failed — target does not exist", http.StatusPreconditionFailed) + return false + } + return true + } + want := strings.Trim(header, `"`) + if want != current { + http.Error(w, "Precondition Failed — ETag mismatch", http.StatusPreconditionFailed) + return false + } + return true +} + +func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { + abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) + if !ok { + http.Error(w, msg, status) + return + } + if strings.HasSuffix(cleanURL, "/") { + http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest) + return + } + + // Stat first so we can choose action=create vs action=write before the + // ACL gate runs — this matters because role grants may include `c` but + // not `w` (or vice versa), and the gate must check the right verb. + existed := false + if info, err := os.Stat(abs); err == nil { + if info.IsDir() { + http.Error(w, "Conflict — a directory exists at this path", http.StatusConflict) + return + } + existed = true + } + + action := policy.ActionCreate + if existed { + action = policy.ActionWrite + } + // .zddc writes always require `a` (admin) regardless of create/overwrite. + if filepath.Base(abs) == ".zddc" { + action = policy.ActionAdmin + } + + if !authorizeAction(cfg, w, r, abs, cleanURL, action) { + return + } + if !checkIfMatch(w, r, abs) { + return + } + + body, ok := readBodyCapped(cfg, w, r) + if !ok { + return + } + + if err := zddc.WriteAtomic(abs, body); err != nil { + auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + // Invalidate ETag cache (static.go memoizes by mtime; rename produces + // a fresh mtime so a stale entry is harmless, but clearing is cheap). + etagCacheM.Delete(abs) + + etag := fileETag(body) + w.Header().Set("ETag", `"`+etag+`"`) + w.Header().Set("X-ZDDC-Source", "fileapi:put") + respStatus := http.StatusCreated + if existed { + respStatus = http.StatusOK + } + w.WriteHeader(respStatus) + auditFile(r, "put", cleanURL, respStatus, len(body), nil) +} + +func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) { + abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) + if !ok { + http.Error(w, msg, status) + return + } + if strings.HasSuffix(cleanURL, "/") { + http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest) + return + } + + info, err := os.Stat(abs) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if info.IsDir() { + http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict) + return + } + + action := policy.ActionDelete + if filepath.Base(abs) == ".zddc" { + action = policy.ActionAdmin + } + if !authorizeAction(cfg, w, r, abs, cleanURL, action) { + return + } + if !checkIfMatch(w, r, abs) { + return + } + + if err := os.Remove(abs); err != nil { + auditFile(r, "delete", cleanURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + etagCacheM.Delete(abs) + + w.Header().Set("X-ZDDC-Source", "fileapi:delete") + w.WriteHeader(http.StatusNoContent) + auditFile(r, "delete", cleanURL, http.StatusNoContent, 0, nil) +} + +func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) { + op := strings.ToLower(strings.TrimSpace(r.Header.Get(headerOp))) + switch op { + case opMove: + serveFileMove(cfg, w, r) + case opMkdir: + serveFileMkdir(cfg, w, r) + case "": + http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest) + default: + http.Error(w, "Bad Request — unknown "+headerOp+" value: "+op, http.StatusBadRequest) + } +} + +func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { + srcAbs, srcURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) + if !ok { + http.Error(w, msg, status) + return + } + if strings.HasSuffix(srcURL, "/") { + http.Error(w, "MOVE source must be a file path", http.StatusBadRequest) + return + } + + dstHeader := r.Header.Get(headerDestination) + if dstHeader == "" { + http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest) + return + } + // Destination is sent as a URL path; decode percent-encoding. + if dec, err := url.PathUnescape(dstHeader); err == nil { + dstHeader = dec + } + dstAbs, dstURL, ok, status, msg := resolveTargetPath(cfg, dstHeader) + if !ok { + http.Error(w, "destination: "+msg, status) + return + } + if strings.HasSuffix(dstURL, "/") { + http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest) + return + } + + // Source must exist as a regular file. + srcInfo, err := os.Stat(srcAbs) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "Not Found", http.StatusNotFound) + } else { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + if srcInfo.IsDir() { + http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict) + return + } + + // Destination must not exist (no implicit overwrite). If-Match on the + // SOURCE is still respected for concurrency on the source bytes. + if _, err := os.Stat(dstAbs); err == nil { + http.Error(w, "Conflict — destination already exists", http.StatusConflict) + return + } else if !errors.Is(err, os.ErrNotExist) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // ACL: source side requires `w` (rename mutates the source); dest + // side requires `c` (creates a new path). Cross-folder moves run + // both gates against potentially different chains. + if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) { + return + } + if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { + return + } + if !checkIfMatch(w, r, srcAbs) { + return + } + + // Ensure destination's parent directory exists. + if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { + auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := os.Rename(srcAbs, dstAbs); err != nil { + // Cross-device or permission errors: report 500 — the client + // will retry or surface the failure to the user. + auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error — rename failed: "+err.Error(), http.StatusInternalServerError) + return + } + etagCacheM.Delete(srcAbs) + etagCacheM.Delete(dstAbs) + + // Compute new ETag from the moved bytes for the response — clients + // that want to keep tracking should pin to this ETag. + if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" { + w.Header().Set("ETag", `"`+etag+`"`) + } + w.Header().Set("X-ZDDC-Source", "fileapi:move") + w.Header().Set("X-ZDDC-Destination", dstURL) + w.WriteHeader(http.StatusOK) + auditFile(r, "move", srcURL+" -> "+dstURL, http.StatusOK, 0, nil) +} + +func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { + abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) + if !ok { + http.Error(w, msg, status) + return + } + + if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) { + return + } + + // Idempotent: if the dir already exists, treat it as success; + // if a file is at the path, conflict. + if info, err := os.Stat(abs); err == nil { + if info.IsDir() { + w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") + w.WriteHeader(http.StatusOK) + auditFile(r, "mkdir", cleanURL, http.StatusOK, 0, nil) + return + } + http.Error(w, "Conflict — a file exists at this path", http.StatusConflict) + return + } + + if err := os.MkdirAll(abs, 0o755); err != nil { + auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Auto-ownership: when the parent directory is one of the + // auto-own special folders (Incoming/Working/Staging) and the + // caller has an authenticated email, write a .zddc into the new + // folder granting the creator full control. The grant is identical + // to what the operator would write by hand — direct email pattern, + // "rwcda" verb set — so the creator can later edit the file + // normally to add collaborators. + if email := EmailFromContext(r); email != "" { + parentName := filepath.Base(filepath.Dir(abs)) + if zddc.IsAutoOwnParent(parentName) { + if err := writeAutoOwnZddc(abs, email); err != nil { + slog.Warn("auto-own .zddc write failed", "path", abs, "err", err) + } + } + } + + w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") + w.WriteHeader(http.StatusCreated) + auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil) +} + +// writeAutoOwnZddc serializes a creator-grant .zddc into newDir. +// Marshals via the same yaml encoder ParseFile reads (round-trip +// guaranteed) and writes atomically via zddc.WriteAtomic. +func writeAutoOwnZddc(newDir, email string) error { + zf := zddc.ZddcFile{ + ACL: zddc.ACLRules{ + Permissions: map[string]string{email: "rwcda"}, + }, + CreatedBy: email, + } + return zddc.WriteFile(newDir, zf) +} + +// auditFile emits a structured log line for each file API operation. +// AccessLogMiddleware already logs every request — this adds an +// op-tagged line so audit consumers can filter by operation without +// pattern-matching on method + path. +func auditFile(r *http.Request, op, path string, status any, bytes int, err error) { + email := EmailFromContext(r) + if email == "" { + email = "anonymous" + } + args := []any{ + "op", op, + "path", path, + "email", email, + "status", fmt.Sprint(status), + "bytes", bytes, + } + if err != nil { + args = append(args, "err", err.Error()) + slog.Warn("file_write", args...) + return + } + slog.Info("file_write", args...) +} diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go new file mode 100644 index 0000000..45561cc --- /dev/null +++ b/zddc/internal/handler/fileapi_test.go @@ -0,0 +1,666 @@ +package handler + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// fileAPITestSetup writes a tree of directories and seed files under a +// temp root and returns a do() helper that builds and runs file API +// requests. The root .zddc grants caller@example.com read+write across +// the tree (single ACL allows both — the internal decider doesn't split +// read/write yet). +// +// seed: relative path → bytes (created as a regular file). +// dirs: relative paths to mkdir. +func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) { + t.Helper() + root = t.TempDir() + + // Root .zddc grants writer access to *@example.com. + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n allow:\n - \"*@example.com\"\n deny: []\n"), 0o644); err != nil { + t.Fatalf("write root .zddc: %v", err) + } + + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + for rel, body := range seed { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir parent of %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatalf("seed %s: %v", rel, err) + } + } + zddc.InvalidateCache(root) + + cfg = config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + MaxWriteBytes: 1024 * 1024, + } + + 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) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return cfg, do, root +} + +func sha32(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:32] +} + +func TestFileAPI_PutCreatesFile(t *testing.T) { + _, do, root := fileAPITestSetup(t, []string{"Incoming"}, nil) + + body := []byte("hello world") + rec := do(http.MethodPut, "/Incoming/note.txt", "alice@example.com", body, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) + } + got, err := os.ReadFile(filepath.Join(root, "Incoming/note.txt")) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != "hello world" { + t.Fatalf("body mismatch: %q", got) + } + wantTag := `"` + sha32(body) + `"` + if got := rec.Header().Get("ETag"); got != wantTag { + t.Fatalf("ETag: want %s, got %s", wantTag, got) + } +} + +func TestFileAPI_PutOverwritesExisting(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/old.txt": "first", + }) + + body := []byte("second") + rec := do(http.MethodPut, "/Incoming/old.txt", "alice@example.com", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("want 200 (overwrite), got %d: %s", rec.Code, rec.Body.String()) + } + got, _ := os.ReadFile(filepath.Join(root, "Incoming/old.txt")) + if string(got) != "second" { + t.Fatalf("body: %q", got) + } +} + +func TestFileAPI_PutAutoCreatesParents(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, nil) + + rec := do(http.MethodPut, "/Incoming/sub/deep/x.bin", "alice@example.com", []byte("data"), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Incoming/sub/deep/x.bin")); err != nil { + t.Fatalf("stat: %v", err) + } +} + +func TestFileAPI_PutDenyForbidden(t *testing.T) { + cfg, do, _ := fileAPITestSetup(t, []string{"Working"}, nil) + + // Tighten ACL to a different domain — alice@example.com no longer + // matches and writes must be 403. + if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), + []byte("acl:\n allow:\n - \"*@allowed.com\"\n deny: []\n"), 0o644); err != nil { + t.Fatalf("rewrite .zddc: %v", err) + } + zddc.InvalidateCache(cfg.Root) + + rec := do(http.MethodPut, "/Working/note.md", "alice@example.com", []byte("nope"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, nil) + + for _, p := range []string{"/.zddc", "/foo/.hidden", "/_app/spoof.html", "/_template/x"} { + rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404 for %s, got %d", p, rec.Code) + } + } +} + +func TestFileAPI_PutOversizeRejected(t *testing.T) { + cfg, _, _ := fileAPITestSetup(t, []string{"Incoming"}, nil) + cfg.MaxWriteBytes = 16 + + body := bytes.Repeat([]byte("A"), 32) + req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("want 413, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_PutTrailingSlashRejected(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, nil) + rec := do(http.MethodPut, "/Incoming/", "alice@example.com", []byte("x"), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestFileAPI_DeleteRemovesFile(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/old.txt": "garbage", + }) + + rec := do(http.MethodDelete, "/Incoming/old.txt", "alice@example.com", nil, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Incoming/old.txt")); !os.IsNotExist(err) { + t.Fatalf("file should be gone, err=%v", err) + } +} + +func TestFileAPI_DeleteMissing404(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, nil) + rec := do(http.MethodDelete, "/Incoming/never-existed.txt", "alice@example.com", nil, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } +} + +func TestFileAPI_DeleteDirectoryConflict(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil) + rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil) + if rec.Code != http.StatusConflict { + t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_MoveRenames(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/old.pdf": "PDF body", + }) + + rec := do(http.MethodPost, "/Incoming/old.pdf", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Incoming/new.pdf", + }) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Incoming/old.pdf")); !os.IsNotExist(err) { + t.Fatalf("source still exists") + } + got, err := os.ReadFile(filepath.Join(root, "Incoming/new.pdf")) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(got) != "PDF body" { + t.Fatalf("dest bytes: %q", got) + } + if dst := rec.Header().Get("X-ZDDC-Destination"); dst != "/Incoming/new.pdf" { + t.Fatalf("destination header: %s", dst) + } +} + +func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/a.txt": "a", + "Incoming/b.txt": "b", + }) + + rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Incoming/b.txt", + }) + if rec.Code != http.StatusConflict { + t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_MoveMissingDestinationHeader(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/a.txt": "a", + }) + rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_MoveCreatesParentDirs(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/a.txt": "hi", + }) + rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Working/sub/a.txt", + }) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Working/sub/a.txt")); err != nil { + t.Fatalf("dest not present: %v", err) + } +} + +func TestFileAPI_PostUnknownOp(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil) + rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "weld", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestFileAPI_PostMissingOp(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil) + rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestFileAPI_MkdirCreates(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, nil) + + rec := do(http.MethodPost, "/Incoming/newfolder/", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) + } + info, err := os.Stat(filepath.Join(root, "Incoming/newfolder")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.IsDir() { + t.Fatalf("not a dir") + } +} + +func TestFileAPI_MkdirIdempotent(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil) + rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", + }) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } +} + +func TestFileAPI_IfMatchEnforced(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, map[string]string{ + "Incoming/x.txt": "v1", + }) + + // Wrong ETag → 412. + rec := do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{ + "If-Match": `"` + strings.Repeat("0", 32) + `"`, + }) + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("want 412, got %d", rec.Code) + } + + // Correct ETag → 200. + correctTag := sha32([]byte("v1")) + rec = do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{ + "If-Match": `"` + correctTag + `"`, + }) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_IfMatchWildcardOnMissing(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil) + rec := do(http.MethodPut, "/Incoming/new.txt", "alice@example.com", []byte("data"), map[string]string{ + "If-Match": `*`, + }) + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("want 412 (wildcard expects existing), got %d", rec.Code) + } +} + +func TestFileAPI_PathTraversalBlocked(t *testing.T) { + _, do, _ := fileAPITestSetup(t, nil, nil) + + rec := do(http.MethodPut, "/../escaped.txt", "alice@example.com", []byte("x"), nil) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest { + t.Fatalf("traversal not blocked: %d", rec.Code) + } +} + +func TestFileAPI_AnonymousDenied(t *testing.T) { + _, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil) + + rec := do(http.MethodPut, "/Incoming/note.txt", "", []byte("x"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403 for anon, got %d", rec.Code) + } +} + +// rolePermissionsTestSetup creates a vendor-exchange shape: +// +// root .zddc: _company:r, _doc_controller:rwcda +// Vendor/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:"" +// roles defined at root. +// +// Returns the same do() helper as fileAPITestSetup. +func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) { + t.Helper() + root = t.TempDir() + + // Root .zddc — company gets r, doc_controller gets rwcda. Roles + // defined here so the vendor subtree's permissions can reference + // them by name. + rootZ := []byte(`roles: + _company: + members: ["*@mycompany.com"] + _doc_controller: + members: [dc@mycompany.com] + vendor_acme: + members: ["*@acme.com"] +acl: + permissions: + _company: r + _doc_controller: rwcda +`) + if err := os.WriteFile(filepath.Join(root, ".zddc"), rootZ, 0o644); err != nil { + t.Fatalf("root .zddc: %v", err) + } + + // Vendor subtree: narrow scope. + vendorDir := filepath.Join(root, "Vendor") + if err := os.MkdirAll(filepath.Join(vendorDir, "Incoming"), 0o755); err != nil { + t.Fatalf("mkdir Vendor/Incoming: %v", err) + } + if err := os.MkdirAll(filepath.Join(vendorDir, "Issued"), 0o755); err != nil { + t.Fatalf("mkdir Vendor/Issued: %v", err) + } + if err := os.MkdirAll(filepath.Join(vendorDir, "Received"), 0o755); err != nil { + t.Fatalf("mkdir Vendor/Received: %v", err) + } + vendorZ := []byte(`acl: + permissions: + vendor_acme: rwcd + _doc_controller: rwcda + _company: "" +`) + if err := os.WriteFile(filepath.Join(vendorDir, ".zddc"), vendorZ, 0o644); err != nil { + t.Fatalf("vendor .zddc: %v", err) + } + + zddc.InvalidateCache(root) + + cfg = config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + MaxWriteBytes: 1024 * 1024, + CascadeMode: "delegated", + } + decider := &policy.InternalDecider{} + + 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, DeciderKey, decider) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return cfg, do, root +} + +func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) { + _, do, _ := rolePermissionsTestSetup(t) + + // Vendor PUTs into their Incoming → 201. + rec := do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data"), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("PUT vendor → Incoming: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + // Vendor overwrites the same file → 200 (rwcd has w). + rec = do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil) + if rec.Code != http.StatusOK { + t.Fatalf("PUT vendor → Incoming overwrite: want 200, got %d", rec.Code) + } +} + +func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) { + _, do, root := rolePermissionsTestSetup(t) + + // Seed an existing Issued file. + if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/spec.pdf"), []byte("FILED"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + // Vendor cannot overwrite — ancestor grant masked to r in Issued. + rec := do(http.MethodPut, "/Vendor/Issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("PUT vendor → Issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String()) + } + // Vendor cannot delete. + rec = do(http.MethodDelete, "/Vendor/Issued/spec.pdf", "rep@acme.com", nil, nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("DELETE vendor → Issued: want 403, got %d", rec.Code) + } + // Vendor cannot create new files — they have no explicit .zddc grant + // at the Issued folder, so the WORM split reduces their inherited + // rwcd to r-only. + rec = do(http.MethodPut, "/Vendor/Issued/new.pdf", "rep@acme.com", []byte("x"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("PUT vendor → Issued (create): want 403 (no explicit grant at Issued), got %d", rec.Code) + } +} + +func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) { + _, do, root := rolePermissionsTestSetup(t) + + // Without a .zddc at Vendor/Issued/ explicitly granting cr, the dc's + // inherited rwcda is masked to r. They cannot create. + rec := do(http.MethodPut, "/Vendor/Issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("dc without explicit grant → Issued: want 403, got %d: %s", rec.Code, rec.Body.String()) + } + + // Operator places an explicit grant at Vendor/Issued/.zddc. Now dc + // has cr at-or-below the WORM folder, which survives the mask. + issuedZ := []byte(`acl: + permissions: + _doc_controller: cr +`) + if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil { + t.Fatalf("write Issued .zddc: %v", err) + } + zddc.InvalidateCache(root) + + rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("dc with explicit grant → Issued: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + got, _ := os.ReadFile(filepath.Join(root, "Vendor/Issued/2026-Q2-spec.pdf")) + if string(got) != "CONTROLLED" { + t.Fatalf("body: %q", got) + } + + // dc still cannot overwrite — explicit grant is cr, no w. + rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("dc PUT overwrite → Issued: want 403, got %d", rec.Code) + } + // dc still cannot delete. + rec = do(http.MethodDelete, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("dc DELETE → Issued: want 403, got %d", rec.Code) + } +} + +func TestFileAPI_WORM_AdminBypass(t *testing.T) { + cfg, do, root := rolePermissionsTestSetup(t) + + // Promote root@example.com to root admin. + rootZ, _ := os.ReadFile(filepath.Join(cfg.Root, ".zddc")) + updated := string(rootZ) + "\nadmins:\n - root@example.com\n" + if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte(updated), 0o644); err != nil { + t.Fatalf("rewrite root .zddc: %v", err) + } + zddc.InvalidateCache(cfg.Root) + + // Seed an Issued file and have root@ delete it (escape hatch). + if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/mistake.pdf"), []byte("oops"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + rec := do(http.MethodDelete, "/Vendor/Issued/mistake.pdf", "root@example.com", nil, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("admin DELETE → Issued: want 204, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFileAPI_AutoMkdirOwnership(t *testing.T) { + _, do, root := rolePermissionsTestSetup(t) + + // Vendor creates a folder under their Incoming. Server should + // auto-write a .zddc granting them rwcda on the new subtree. + rec := do(http.MethodPost, "/Vendor/Incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + autoZ := filepath.Join(root, "Vendor/Incoming/2026-05-15-issue/.zddc") + data, err := os.ReadFile(autoZ) + if err != nil { + t.Fatalf("auto .zddc not written: %v", err) + } + body := string(data) + if !strings.Contains(body, "created_by: rep@acme.com") { + t.Errorf("auto .zddc missing created_by: %s", body) + } + if !strings.Contains(body, "rep@acme.com: rwcda") { + t.Errorf("auto .zddc missing email→rwcda grant: %s", body) + } + + // Now the cascade caches are stale because we didn't go through + // WriteFile here; the server's writeAutoOwnZddc DID call WriteFile + // (via zddc.WriteFile → InvalidateCache). Confirm the vendor can + // now PUT a brand-new file inside their owned folder where they + // otherwise wouldn't have ACL admin rights. + zddc.InvalidateCache(root) + rec = do(http.MethodPut, "/Vendor/Incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +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") + if err := os.WriteFile(filepath.Join(root, "Vendor/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). + rec := do(http.MethodPost, "/Vendor/Issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + autoZ := filepath.Join(root, "Vendor/Issued/2026-Q2/.zddc") + if _, err := os.Stat(autoZ); !os.IsNotExist(err) { + t.Errorf("auto .zddc should NOT be written under Issued; got err=%v", err) + } +} + +func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) { + cfg, _, root := rolePermissionsTestSetup(t) + cfg.CascadeMode = "strict" + + // Add a strict-mode lockout at root: deny vendor_acme everywhere. + rootZ, _ := os.ReadFile(filepath.Join(root, ".zddc")) + updated := strings.Replace(string(rootZ), "_doc_controller: rwcda\n", + "_doc_controller: rwcda\n vendor_acme: \"\"\n", 1) + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(updated), 0o644); err != nil { + t.Fatalf("rewrite root: %v", err) + } + zddc.InvalidateCache(root) + + // Build a strict-mode decider so the file API uses the new mode. + decider := &policy.InternalDecider{Mode: zddc.ModeStrict} + + doStrict := func(method, target, email string, body []byte) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, target, bytes.NewReader(body)) + } else { + req = httptest.NewRequest(method, target, nil) + } + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, DeciderKey, decider) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + + // Vendor's leaf rwcd grant in Vendor/.zddc is overridden by the + // root deny under strict mode. + rec := doStrict(http.MethodPut, "/Vendor/Incoming/blocked.pdf", "rep@acme.com", []byte("nope")) + if rec.Code != http.StatusForbidden { + t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index 04f39c4..324aeaa 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -58,6 +58,13 @@ func EmailFromContext(r *http.Request) string { return "" } +// WithEmail returns a context carrying email under EmailKey. Test seam +// for handlers that look up the authenticated user via EmailFromContext; +// production traffic gets the same value injected by ACLMiddleware. +func WithEmail(ctx context.Context, email string) context.Context { + return context.WithValue(ctx, EmailKey, email) +} + // DeciderFromContext extracts the policy decider from the request // context. Returns the internal decider as a fallback if none was // installed — this matches the "no OPA configured" semantics and diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index fb3c971..d49e3b2 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -193,6 +193,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques IndexPath string `json:"index_path"` EmailHeader string `json:"email_header"` CORSOrigins []string `json:"cors_origins"` + CascadeMode string `json:"cascade_mode"` } writeJSON(w, response{ Root: cfg.Root, @@ -204,6 +205,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques IndexPath: cfg.IndexPath, EmailHeader: cfg.EmailHeader, CORSOrigins: cfg.CORSOrigins, + CascadeMode: cfg.CascadeMode, }) } diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 516c203..492b27b 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -58,18 +58,55 @@ import ( // External Rego policies can: // - read input.user.email (string) // - read input.path (string) +// - read input.action ("read" | "write"); empty/absent ≡ "read" // - walk input.policy_chain.levels[].acl.{allow,deny} for // custom cascade semantics, or read the pre-resolved // input.policy_chain.has_any_file when implementing the // same default-deny rule we use internally. +// +// Action distinguishes read (GET/HEAD on listings, files, app HTML) +// from write (PUT, DELETE, POST/move on the file API). The internal +// decider treats both identically — any allow grants full CRUD, +// matching the model in place before the file API existed (anyone +// with read access also had OS-level write via the mounted share). +// External Rego policies can split the two by inspecting input.action. type AllowInput struct { User struct { Email string `json:"email"` } `json:"user"` Path string `json:"path"` + Action string `json:"action,omitempty"` PolicyChain *SerializableChain `json:"policy_chain,omitempty"` } +// Action constants used in AllowInput.Action. Empty string is also +// accepted for back-compat with callers that don't specify a verb. +const ( + ActionRead = "read" // listing + reading file bytes + ActionWrite = "write" // overwriting an existing file (legacy alias for the historical write-vs-read split) + ActionCreate = "create" // creating a new file or directory + ActionDelete = "delete" // deleting a file + ActionAdmin = "admin" // modifying ACL / .zddc / role definitions +) + +// actionVerb maps an Action string to the zddc.VerbSet bit it requires. +// Returns the read verb for unrecognized values so the internal +// decider stays restrictive on unknown action labels. +func actionVerb(action string) zddc.VerbSet { + switch action { + case ActionWrite: + return zddc.VerbW + case ActionCreate: + return zddc.VerbC + case ActionDelete: + return zddc.VerbD + case ActionAdmin: + return zddc.VerbA + default: + return zddc.VerbR + } +} + // SerializableChain is a JSON-friendly view of zddc.PolicyChain. // We don't tag zddc.PolicyChain itself because it's tightly coupled // to the parser; the duplication is one struct. @@ -92,6 +129,13 @@ type Config struct { URL string // raw value: "", "internal", "http(s)://...", "unix:///path" FailOpen bool // external mode only: on transport error, allow instead of deny CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache. + + // CascadeMode controls how the InternalDecider walks the ACL chain: + // "delegated" (default — leaf grants override ancestor denies) or + // "strict" (ancestor explicit-deny is absolute; NIST AC-6). + // External deciders ignore this — Rego policies access the chain + // directly and implement either semantic themselves. + CascadeMode string } // New constructs a Decider per cfg.URL semantics. @@ -106,8 +150,9 @@ type Config struct { // // Returns an error if URL is unrecognized. func New(cfg Config) (Decider, error) { + mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode) if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") { - return &InternalDecider{}, nil + return &InternalDecider{Mode: mode}, nil } var inner Decider var err error @@ -140,10 +185,16 @@ func New(cfg Config) (Decider, error) { return &cachingDecider{inner: inner, ttl: ttl}, nil } -// InternalDecider routes Allow through zddc.AllowedWithChain. No -// network, no Rego, no new dependencies — same Go evaluator the -// existing test suite covers. -type InternalDecider struct{} +// InternalDecider routes Allow through zddc.AllowedAction with the +// configured cascade mode and applies the Issued/Received WORM mask +// post-decision. No network, no Rego, no new dependencies. +// +// The decider does NOT consult the admins:/IsAdmin escape hatch — +// callers in the handler package wire IsAdmin / IsSubtreeAdmin around +// the decision. Admins bypass the WORM mask there as well. +type InternalDecider struct { + Mode zddc.CascadeMode +} func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) { chain := zddc.PolicyChain{} @@ -151,7 +202,28 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro chain.Levels = input.PolicyChain.Levels chain.HasAnyFile = input.PolicyChain.HasAnyFile } - return zddc.AllowedWithChain(chain, input.User.Email), nil + 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). + // + // 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 + } + } + + return zddc.AllowedAction(chain, email, verb, d.Mode), nil } // HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured @@ -240,9 +312,29 @@ func (d *HTTPDecider) failResult(err error) (bool, error) { // AllowFromChain is a convenience for callers that already have a // PolicyChain in hand. Equivalent to constructing AllowInput manually -// from (chain, email, path) and calling d.Allow. +// from (chain, email, path) and calling d.Allow. Implies "read". +// +// New callers should use AllowActionFromChain with an explicit verb so +// the audit/policy stream records intent and the internal decider can +// apply the right verb-specific check. func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) { - in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)} + return AllowActionFromChain(ctx, d, chain, email, path, ActionRead) +} + +// AllowWriteFromChain is the legacy write-action helper. Newer callers +// should pick the specific verb (ActionCreate / ActionWrite / +// ActionDelete / ActionAdmin) via AllowActionFromChain instead. +func AllowWriteFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) { + return AllowActionFromChain(ctx, d, chain, email, path, ActionWrite) +} + +// AllowActionFromChain is the canonical access-decision helper. +// External Rego policies can branch on input.action to differentiate +// among the five verbs (read / write / create / delete / admin). The +// internal decider maps each action to its zddc.VerbSet bit and walks +// the cascade in the configured mode (delegated / strict). +func AllowActionFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path, action string) (bool, error) { + in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)} in.User.Email = email return d.Allow(ctx, in) } diff --git a/zddc/internal/zddc/acl.go b/zddc/internal/zddc/acl.go index 49bfa06..ce40c92 100644 --- a/zddc/internal/zddc/acl.go +++ b/zddc/internal/zddc/acl.go @@ -2,38 +2,165 @@ package zddc import "strings" -// AllowedAtLevel checks whether email is explicitly allowed or denied by a single -// .zddc level. Returns (decision, matched): -// - (false, true) — email matched a deny pattern → deny -// - (true, true) — email matched an allow pattern → allow -// - (false, false) — no match in this level → keep walking up +// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for +// callers that only need the legacy boolean read decision. New code +// should call GrantedVerbsAtLevel directly. func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) { - // deny checked first - for _, pattern := range level.ACL.Deny { - if MatchesPattern(pattern, email) { - return false, true - } + chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true} + v, m := GrantedVerbsAtLevel(chain, 0, email) + if !m { + return false, false } - for _, pattern := range level.ACL.Allow { - if MatchesPattern(pattern, email) { - return true, true - } - } - return false, false + return v.Has(VerbR), true } -// AllowedWithChain evaluates a PolicyChain bottom-up (deepest level first). -// First explicit match (allow or deny) wins. -// If no level matches and HasAnyFile is false → allow (no rules = public). -// If no level matches and HasAnyFile is true → deny (user not on any list). +// GrantedVerbsAtLevel computes the verb set granted to email at +// chain.Levels[levelIdx]. Returns (set, matched): +// - matched=false → no entry in this level matches the user; cascade walks on +// - matched=true, set={} → an entry matched with the empty verb set; explicit deny +// - matched=true, set!={} → union of verb sets from every matching entry +// +// Role lookups for principal keys without "@" use RoleMembers, which +// walks levelIdx → root for the closest definition. +// +// Legacy acl.allow / acl.deny entries are folded in here (rather than at +// parse time) so this function works correctly on test-constructed +// ZddcFile literals as well as parser output. +func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) { + if levelIdx < 0 || levelIdx >= len(chain.Levels) { + return 0, false + } + level := chain.Levels[levelIdx] + perms := effectivePermissions(level.ACL) + if len(perms) == 0 { + return 0, false + } + + matched := false + deniedExplicit := false + var grant VerbSet + for principal, verbStr := range perms { + if !MatchesPrincipal(principal, email, chain, levelIdx) { + continue + } + matched = true + v, _ := ParseVerbSet(verbStr) // unknown letters silently dropped + if verbStr == "" { + deniedExplicit = true + continue + } + grant = grant.Union(v) + } + if !matched { + return 0, false + } + if deniedExplicit { + // Empty-set match wins over any grant entries at the same level — + // explicit deny is always more specific than a permissive role + // membership at the same scope. + return 0, true + } + return grant, true +} + +// effectivePermissions returns the union of acl.permissions and the +// legacy acl.allow / acl.deny fields, with permissions winning on +// collision. Returns nil if all three are empty. Does not mutate rules. +func effectivePermissions(rules ACLRules) map[string]string { + if len(rules.Permissions) == 0 && len(rules.Allow) == 0 && len(rules.Deny) == 0 { + return nil + } + out := make(map[string]string, len(rules.Permissions)+len(rules.Allow)+len(rules.Deny)) + for _, pat := range rules.Allow { + out[pat] = "rwcd" + } + for _, pat := range rules.Deny { + out[pat] = "" + } + for k, v := range rules.Permissions { + out[k] = v + } + return out +} + +// AllowedWithChain evaluates a PolicyChain leaf→root (deepest level first) +// for the read action. Preserved for legacy callers and existing read paths +// that haven't migrated to AllowedAction yet. func AllowedWithChain(chain PolicyChain, email string) bool { - for i := len(chain.Levels) - 1; i >= 0; i-- { - decision, matched := AllowedAtLevel(chain.Levels[i], email) - if matched { - return decision + return AllowedAction(chain, email, VerbR, ModeDelegated) +} + +// AllowedAction evaluates a PolicyChain for a specific verb and cascade mode. +// Thin wrapper around EffectiveVerbs that surfaces the boolean answer. +func AllowedAction(chain PolicyChain, email string, verb VerbSet, mode CascadeMode) bool { + return EffectiveVerbs(chain, email, mode).Has(verb) +} + +// EffectiveVerbs computes the verb set granted to email by the cascade. +// Walks the full chain and applies the default-allow rule (no .zddc +// anywhere → public access). +func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet { + v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode) + if v == 0 && !chain.HasAnyFile { + // Public-tree default: empty chain with no .zddc files anywhere + // → grant everything. EffectiveVerbsRange returns 0 in this + // case because it has no opinion on default semantics outside + // a sub-range walk; the full-chain wrapper applies the rule. + return VerbAll + } + return v +} + +// EffectiveVerbsRange computes the verb set granted by walking only +// chain.Levels[fromIdx:toIdx] for matching permission entries. Role +// definitions are still looked up over the FULL chain via +// GrantedVerbsAtLevel → MatchesPrincipal → lookupRoleMembers, so an +// ancestor's role definition remains visible to a sub-range walk. +// +// Used by the WORM split: above-the-WORM-folder and at-or-below-the- +// WORM-folder are evaluated as separate ranges, then their grants are +// masked and unioned. +// +// Cascade mode controls whether ancestor explicit-denies are absolute +// (Strict) or can be overridden by a leaf grant (Delegated). The +// strict-mode pass is restricted to the same range — splitting the +// chain implies splitting the strict-mode walk too. +// +// This function does NOT consult the admins:/IsAdmin escape hatch and +// does NOT apply the Issued/Received WORM mask. +func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mode CascadeMode) VerbSet { + if fromIdx < 0 { + fromIdx = 0 + } + if toIdx > len(chain.Levels) { + toIdx = len(chain.Levels) + } + if fromIdx >= toIdx { + // Empty range — no levels to consult. Caller is responsible + // for the default-deny semantics in this case (typically the + // caller has another range to combine with). + return 0 + } + if mode == ModeStrict { + for i := fromIdx; i < toIdx; i++ { + grant, matched := GrantedVerbsAtLevel(chain, i, email) + if matched && grant == 0 { + return 0 + } } } - return !chain.HasAnyFile + for i := toIdx - 1; i >= fromIdx; i-- { + grant, matched := GrantedVerbsAtLevel(chain, i, email) + if !matched { + continue + } + return grant + } + // No match in range. The "no .zddc anywhere → public" default is + // applied by the EffectiveVerbs wrapper, not here, because callers + // using sub-ranges (e.g. WORM split) want a sub-range with no match + // to contribute nothing rather than implicitly granting everything. + return 0 } // MatchesPattern checks if email matches a glob pattern. diff --git a/zddc/internal/zddc/cascade_mode.go b/zddc/internal/zddc/cascade_mode.go new file mode 100644 index 0000000..6db7aa0 --- /dev/null +++ b/zddc/internal/zddc/cascade_mode.go @@ -0,0 +1,48 @@ +package zddc + +// CascadeMode selects the access-decision algorithm used by AllowedAction. +// +// ModeDelegated (default) preserves the historical commercial-tenant +// behavior: the cascade walks leaf→root and the first level with a +// matching entry decides. Subtree allows can override ancestor denies — +// this is the load-bearing delegation primitive that lets a subtree +// owner grant access without root-admin involvement. +// +// ModeStrict implements the federal posture (NIST AC-6 "least +// privilege"): a deny anywhere in the ancestor chain is absolute and +// cannot be overridden by a leaf grant. Implemented as a two-pass +// evaluation — first walk root→leaf for any matching explicit deny, +// then walk leaf→root for the grant. +// +// The mode is operator-controlled at startup via --cascade-mode (config +// flag) or ZDDC_CASCADE_MODE (env var). Subtree .zddc files cannot +// override the mode — it is a deployment-wide policy. +type CascadeMode int + +const ( + ModeDelegated CascadeMode = iota + ModeStrict +) + +// String returns the operator-facing name (matches the flag value). +func (m CascadeMode) String() string { + switch m { + case ModeStrict: + return "strict" + default: + return "delegated" + } +} + +// ParseCascadeMode resolves a flag/env string to a CascadeMode. Empty +// or unrecognized input defaults to ModeDelegated; the caller can warn +// on unrecognized values, but the safe default is the existing behavior. +func ParseCascadeMode(s string) (CascadeMode, bool) { + switch s { + case "", "delegated": + return ModeDelegated, true + case "strict": + return ModeStrict, true + } + return ModeDelegated, false +} diff --git a/zddc/internal/zddc/cascade_mode_test.go b/zddc/internal/zddc/cascade_mode_test.go new file mode 100644 index 0000000..fa68b3b --- /dev/null +++ b/zddc/internal/zddc/cascade_mode_test.go @@ -0,0 +1,108 @@ +package zddc + +import "testing" + +// helpers + +func chain(levels ...ZddcFile) PolicyChain { + return PolicyChain{Levels: levels, HasAnyFile: len(levels) > 0} +} + +func perms(p map[string]string) ZddcFile { + return ZddcFile{ACL: ACLRules{Permissions: p}} +} + +// TestDelegated_LeafGrantOverridesAncestorDeny verifies the historical +// commercial behavior preserved as ModeDelegated. +func TestDelegated_LeafGrantOverridesAncestorDeny(t *testing.T) { + c := chain( + perms(map[string]string{"vendor_acme": ""}), // root: deny + ZddcFile{ // mid: define the role + ACL: ACLRules{}, + Roles: map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}}, + }, + perms(map[string]string{"vendor_acme": "rwcd"}), // leaf: allow + ) + // Need the role definition to flow up to root for the deny entry to + // match acme members. Add the role at root too. + c.Levels[0].Roles = map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}} + + if !AllowedAction(c, "rep@acme.com", VerbR, ModeDelegated) { + t.Errorf("delegated mode: leaf rwcd should override root deny for read") + } + if !AllowedAction(c, "rep@acme.com", VerbW, ModeDelegated) { + t.Errorf("delegated mode: leaf rwcd should override root deny for write") + } +} + +func TestStrict_AncestorDenyAbsolute(t *testing.T) { + c := chain( + ZddcFile{ + ACL: ACLRules{Permissions: map[string]string{"vendor_acme": ""}}, + Roles: map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}}, + }, + ZddcFile{ + ACL: ACLRules{Permissions: map[string]string{"vendor_acme": "rwcd"}}, + }, + ) + if AllowedAction(c, "rep@acme.com", VerbR, ModeStrict) { + t.Errorf("strict mode: root deny should not be overridable by leaf grant") + } + if AllowedAction(c, "rep@acme.com", VerbW, ModeStrict) { + t.Errorf("strict mode: root deny should not be overridable by leaf grant (write)") + } +} + +func TestStrict_NoAncestorDenyMeansLeafDecides(t *testing.T) { + c := chain( + ZddcFile{ + ACL: ACLRules{Permissions: map[string]string{"_company": "r"}}, + Roles: map[string]Role{"_company": {Members: []string{"*@mycompany.com"}}}, + }, + perms(map[string]string{"alice@mycompany.com": "rwcd"}), + ) + if !AllowedAction(c, "alice@mycompany.com", VerbW, ModeStrict) { + t.Errorf("strict: leaf grant should decide when no ancestor explicit-deny matches") + } +} + +func TestStrict_AncestorDenyOnRoleSpecificEntryDoesNotBlockOthers(t *testing.T) { + // Root denies vendor_acme but grants _company. acme is locked out + // under strict; mycompany staff still see leaf grants. + c := chain( + ZddcFile{ + ACL: ACLRules{Permissions: map[string]string{ + "vendor_acme": "", + "_company": "r", + }}, + Roles: map[string]Role{ + "vendor_acme": {Members: []string{"*@acme.com"}}, + "_company": {Members: []string{"*@mycompany.com"}}, + }, + }, + perms(map[string]string{"_company": "rwcd"}), + ) + if AllowedAction(c, "rep@acme.com", VerbR, ModeStrict) { + t.Errorf("strict: acme should be denied (root deny is absolute)") + } + if !AllowedAction(c, "alice@mycompany.com", VerbW, ModeStrict) { + t.Errorf("strict: mycompany's leaf grant should still apply (no matching ancestor deny)") + } +} + +func TestParseCascadeMode(t *testing.T) { + cases := map[string]CascadeMode{ + "": ModeDelegated, + "delegated": ModeDelegated, + "strict": ModeStrict, + } + for in, want := range cases { + got, ok := ParseCascadeMode(in) + if !ok || got != want { + t.Errorf("ParseCascadeMode(%q) = %v %v, want %v true", in, got, ok, want) + } + } + if _, ok := ParseCascadeMode("loose"); ok { + t.Errorf("ParseCascadeMode(\"loose\") should be ok=false") + } +} diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 2cba941..d15eb59 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -6,14 +6,43 @@ import ( "gopkg.in/yaml.v3" ) -// ACLRules holds email allow/deny lists. +// ACLRules holds the access-control rules at one cascade level. +// +// Three input forms, all merged at parse time into a single map keyed +// by principal (Permissions): +// +// - acl.permissions: { principal → verb-set } — the canonical form. +// Principal is an email pattern (contains "@") or a role name +// (no "@"); roles are looked up via ZddcFile.Roles in this file +// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a}; +// empty string is an explicit deny. +// +// - acl.allow: [pattern, ...] — legacy. Each pattern becomes +// Permissions[pattern] = "rwcd" at parse time. +// +// - acl.deny: [pattern, ...] — legacy. Each pattern becomes +// Permissions[pattern] = "" at parse time (explicit deny). +// +// Allow and Deny are retained on the struct for round-trip fidelity +// (and so existing operator-authored .zddc files render unchanged in +// the admin UI); the cascade evaluator reads only Permissions. // // JSON tags are present so this type round-trips cleanly when included // in the external-OPA input body (see internal/policy). The canonical // in-repo serialization is YAML; JSON is only used for OPA queries. type ACLRules struct { - Allow []string `yaml:"allow" json:"allow,omitempty"` - Deny []string `yaml:"deny" json:"deny,omitempty"` + Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"` + Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"` + Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"` +} + +// Role is the named principal-grouping primitive. Members are email +// patterns (same syntax as the legacy allow/deny entries — see +// MatchesPattern). A role defined at level L is in scope at L and all +// descendants; a level closer to the leaf may shadow an ancestor's +// role definition by redefining the same name. +type Role struct { + Members []string `yaml:"members,omitempty" json:"members,omitempty"` } // ZddcFile represents the parsed contents of a .zddc configuration file. @@ -54,6 +83,17 @@ type ZddcFile struct { Title string `yaml:"title" json:"title,omitempty"` Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"` + + // Roles are named principal groups available at this level and below. + // See Role for member syntax. + Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"` + + // CreatedBy records the email of the user who triggered the .zddc's + // creation via the file API's mkdir post-hook (Incoming/Working/Staging + // only). It is an audit field; the cascade evaluator does not consult + // it. The auto-generated .zddc grants the creator's email directly via + // ACL.Permissions, the same way operators grant access to anyone else. + CreatedBy string `yaml:"created_by,omitempty" json:"created_by,omitempty"` } // ParseFile reads and parses a .zddc YAML file. @@ -71,5 +111,30 @@ func ParseFile(path string) (ZddcFile, error) { if err := yaml.Unmarshal(data, &zf); err != nil { return ZddcFile{}, err } + mergeLegacyACL(&zf.ACL) return zf, nil } + +// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the +// canonical ACL.Permissions map so cascade evaluators only need to +// consult one place. Existing entries in Permissions take precedence +// (operators who specified both forms get the new form's value); +// allow entries become "rwcd" grants, deny entries become "" denies. +func mergeLegacyACL(rules *ACLRules) { + if len(rules.Allow) == 0 && len(rules.Deny) == 0 { + return + } + if rules.Permissions == nil { + rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny)) + } + for _, pat := range rules.Allow { + if _, present := rules.Permissions[pat]; !present { + rules.Permissions[pat] = "rwcd" + } + } + for _, pat := range rules.Deny { + if _, present := rules.Permissions[pat]; !present { + rules.Permissions[pat] = "" + } + } +} diff --git a/zddc/internal/zddc/roles.go b/zddc/internal/zddc/roles.go new file mode 100644 index 0000000..b7c7f0e --- /dev/null +++ b/zddc/internal/zddc/roles.go @@ -0,0 +1,191 @@ +package zddc + +import ( + "sort" + "strings" +) + +// VerbSet is a bitmask over the five permission verbs r, w, c, d, a. +// Construct via ParseVerbSet (tolerant of any letter order, ignores +// duplicates and whitespace, rejects unknown letters as a deny). The +// canonical string form sorts to "rwcda" — see VerbSet.String. +type VerbSet uint8 + +const ( + VerbR VerbSet = 1 << iota // read file bytes / list directory + VerbW // overwrite existing / rename existing + VerbC // create new file or directory + VerbD // delete file + VerbA // modify ACL of this subtree + + VerbAll = VerbR | VerbW | VerbC | VerbD | VerbA + + // VerbsRWCD is the verb set the legacy acl.allow translation grants — + // every right except admin (which always required the admins: list). + VerbsRWCD = VerbR | VerbW | VerbC | VerbD + + // VerbsRC is the WORM-mask survivor: read + create only. Drop boxes + // (doc controller filing into Issued/Received) and any other principal + // with cascade-derived broader rights end up here once the mask runs. + VerbsRC = VerbR | VerbC +) + +// ParseVerbSet parses a verb-set string like "rwcd" or "cra". Empty +// string returns an explicit-deny (zero VerbSet). Any unknown letter +// returns ok=false; callers that round-trip operator-authored YAML +// should surface this as a parse error rather than silently dropping +// the entry. +func ParseVerbSet(s string) (VerbSet, bool) { + var v VerbSet + for _, r := range s { + switch r { + case 'r', 'R': + v |= VerbR + case 'w', 'W': + v |= VerbW + case 'c', 'C': + v |= VerbC + case 'd', 'D': + v |= VerbD + case 'a', 'A': + v |= VerbA + case ' ', '\t': + // tolerate whitespace + default: + return 0, false + } + } + return v, true +} + +// String returns the canonical "rwcda" ordering with only the verbs +// present in the set. The empty set serializes to "" — round-trippable +// as the explicit-deny entry. +func (v VerbSet) String() string { + var b strings.Builder + if v&VerbR != 0 { + b.WriteByte('r') + } + if v&VerbW != 0 { + b.WriteByte('w') + } + if v&VerbC != 0 { + b.WriteByte('c') + } + if v&VerbD != 0 { + b.WriteByte('d') + } + if v&VerbA != 0 { + b.WriteByte('a') + } + return b.String() +} + +// Has reports whether the set contains every verb in mask. +func (v VerbSet) Has(mask VerbSet) bool { return v&mask == mask } + +// Union returns the verb-wise union. +func (v VerbSet) Union(o VerbSet) VerbSet { return v | o } + +// Intersect returns the verb-wise intersection. +func (v VerbSet) Intersect(o VerbSet) VerbSet { return v & o } + +// IsPrincipalRole reports whether a Permissions key is a role +// reference (no "@") rather than a direct email pattern. This is the +// disambiguation rule: any principal containing "@" is treated as an +// email pattern matched via MatchesPattern; everything else is a role +// name looked up via Roles maps in the cascade. +func IsPrincipalRole(principal string) bool { + return !strings.Contains(principal, "@") +} + +// RoleMembers returns the member-pattern list for roleName as visible +// at chain.Levels[levelIdx]. Lookup walks levelIdx → root and returns +// the first definition found (closer-to-leaf wins). Returns nil if no +// level in the visible chain defines the role. +// +// Levels are stored root (index 0) → leaf (last index), matching the +// EffectivePolicy convention. +func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string { + if levelIdx < 0 || levelIdx >= len(chain.Levels) { + return nil + } + for i := levelIdx; i >= 0; i-- { + role, ok := chain.Levels[i].Roles[roleName] + if !ok { + continue + } + return role.Members + } + return nil +} + +// MatchesPrincipal reports whether email satisfies the given Permissions +// key at chain.Levels[levelIdx]. +// +// Resolution order: +// +// 1. Principals containing "@" are always email patterns; dispatch to +// MatchesPattern. +// 2. Principals without "@" are role-or-pattern. Look up the name in +// the cascade's roles. If a role definition is found, match the +// user against the role's members. If no role definition exists +// anywhere in the cascade, fall back to MatchesPattern. The +// fallback preserves legacy patterns like "*" or "*example.com" +// that pre-date the roles feature. +func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool { + if !IsPrincipalRole(principal) { + return MatchesPattern(principal, email) + } + members, defined := lookupRoleMembers(chain, levelIdx, principal) + if !defined { + // Legacy pattern compatibility — bare wildcards / unqualified + // strings continue to match via the email-pattern matcher. + return MatchesPattern(principal, email) + } + for _, m := range members { + if MatchesPattern(m, email) { + return true + } + } + return false +} + +// lookupRoleMembers returns the member list and whether the role was +// defined anywhere in the visible chain. Distinguishes "role exists +// but is empty" (defined=true, empty members) from "role not defined" +// (defined=false), which the principal-fallback logic depends on. +func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) { + if levelIdx < 0 || levelIdx >= len(chain.Levels) { + return nil, false + } + for i := levelIdx; i >= 0; i-- { + role, ok := chain.Levels[i].Roles[roleName] + if !ok { + continue + } + return role.Members, true + } + return nil, false +} + +// MatchingPrincipals returns the keys of level.ACL.Permissions whose +// principal matches email at chain.Levels[levelIdx]. Output is sorted +// for stable iteration in tests and audit logs. +func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string { + if levelIdx < 0 || levelIdx >= len(chain.Levels) { + return nil + } + level := chain.Levels[levelIdx] + if len(level.ACL.Permissions) == 0 { + return nil + } + var out []string + for principal := range level.ACL.Permissions { + if MatchesPrincipal(principal, email, chain, levelIdx) { + out = append(out, principal) + } + } + sort.Strings(out) + return out +} diff --git a/zddc/internal/zddc/roles_test.go b/zddc/internal/zddc/roles_test.go new file mode 100644 index 0000000..0f36ff0 --- /dev/null +++ b/zddc/internal/zddc/roles_test.go @@ -0,0 +1,124 @@ +package zddc + +import "testing" + +func TestParseVerbSetRoundTrip(t *testing.T) { + cases := []struct { + in string + out string + }{ + {"", ""}, + {"r", "r"}, + {"rw", "rw"}, + {"wr", "rw"}, // canonical reorder + {"rwcd", "rwcd"}, + {"adcwr", "rwcda"}, // canonical reorder + {"RWCDA", "rwcda"}, // case-insensitive + {" r w c ", "rwc"}, // whitespace tolerated + } + for _, tc := range cases { + v, ok := ParseVerbSet(tc.in) + if !ok { + t.Errorf("ParseVerbSet(%q) ok=false", tc.in) + continue + } + if got := v.String(); got != tc.out { + t.Errorf("ParseVerbSet(%q).String() = %q, want %q", tc.in, got, tc.out) + } + } +} + +func TestParseVerbSetUnknownLetter(t *testing.T) { + if _, ok := ParseVerbSet("rwx"); ok { + t.Errorf("ParseVerbSet(\"rwx\") = ok=true, want false") + } +} + +func TestVerbSetHasAndUnion(t *testing.T) { + rw, _ := ParseVerbSet("rw") + cd, _ := ParseVerbSet("cd") + if !rw.Has(VerbR) { + t.Errorf("rw should have R") + } + if rw.Has(VerbC) { + t.Errorf("rw should not have C") + } + if got := rw.Union(cd).String(); got != "rwcd" { + t.Errorf("rw|cd = %q, want rwcd", got) + } +} + +func TestIsPrincipalRole(t *testing.T) { + cases := map[string]bool{ + "alice@example.com": false, + "*@example.com": false, + "alice@*": false, + "_doc_controller": true, + "vendor_acme": true, + "*": true, // legacy bare wildcard — treated as role-or-pattern + } + for in, want := range cases { + if got := IsPrincipalRole(in); got != want { + t.Errorf("IsPrincipalRole(%q) = %v, want %v", in, got, want) + } + } +} + +func TestRoleMembersClosestLeafWins(t *testing.T) { + chain := PolicyChain{ + Levels: []ZddcFile{ + // root: role defined with one set of members + {Roles: map[string]Role{ + "editors": {Members: []string{"alice@example.com"}}, + }}, + // child: shadows with a different set + {Roles: map[string]Role{ + "editors": {Members: []string{"bob@example.com"}}, + }}, + }, + HasAnyFile: true, + } + got := RoleMembers(chain, 1, "editors") + if len(got) != 1 || got[0] != "bob@example.com" { + t.Errorf("leaf shadow failed: %v", got) + } + // At root level, only the root definition is visible. + got = RoleMembers(chain, 0, "editors") + if len(got) != 1 || got[0] != "alice@example.com" { + t.Errorf("root visibility failed: %v", got) + } +} + +func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) { + // No roles defined; bare "*" and "*example.com" must still match + // via legacy email-pattern semantics. + chain := PolicyChain{ + Levels: []ZddcFile{{}}, + HasAnyFile: true, + } + if !MatchesPrincipal("*", "alice@example.com", chain, 0) { + t.Errorf("bare * should match any email via legacy fallback") + } + if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) { + t.Errorf("*example.com should match alice@example.com via legacy fallback") + } +} + +func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) { + // When a role is defined, the role match wins; legacy fallback is + // not consulted. + chain := PolicyChain{ + Levels: []ZddcFile{{ + Roles: map[string]Role{ + "vendor_acme": {Members: []string{"*@acme.com"}}, + }, + }}, + HasAnyFile: true, + } + if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0) { + t.Errorf("rep@acme.com should match role vendor_acme") + } + if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) { + t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed") + } +} diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go new file mode 100644 index 0000000..c5c99f3 --- /dev/null +++ b/zddc/internal/zddc/special.go @@ -0,0 +1,122 @@ +package zddc + +import ( + "path/filepath" + "strings" +) + +// SpecialFolderNames is the canonical list of folder names that drive +// per-tool availability rules and post-cascade access-decision behaviors. +// Centralized here so apps/availability and the access-control evaluator +// share one source of truth. +// +// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator +// becomes the new subtree's admin). +// - "Working" — internal pre-publication workspace; mkdir auto-ownership. +// - "Staging" — outbound transmittal staging; mkdir auto-ownership. +// - "Issued" — immutable archive of documents we sent out. WORM mask +// strips w/d/a from non-admin principals. +// - "Received" — immutable archive of documents we accepted. Same WORM +// semantics as Issued. +// +// Names are case-sensitive and exactly capitalized — operators name their +// folders this way by convention. A folder spelled differently (e.g. +// "incoming") is just a regular folder with no special semantics. +var SpecialFolderNames = []string{ + "Incoming", + "Working", + "Staging", + "Issued", + "Received", +} + +// AutoOwnFolderNames is the subset of SpecialFolderNames where the file +// API's mkdir post-hook auto-writes a creator-owned .zddc into the new +// subdirectory. Issued / Received are deliberately excluded — filing in +// the immutable archive should not create owned subtrees inside it. +var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"} + +// WormFolderNames is the subset of SpecialFolderNames covered by the +// post-cascade WORM mask. Any path whose chain crosses one of these +// names has w/d/a stripped from non-admin principals. +var WormFolderNames = []string{"Issued", "Received"} + +// IsAutoOwnParent reports whether a folder named name should trigger +// the mkdir auto-ownership .zddc write when a child is created inside +// it. Used by the file API's mkdir handler. +func IsAutoOwnParent(name string) bool { + for _, n := range AutoOwnFolderNames { + if name == n { + return true + } + } + return false +} + +// IsWormPath reports whether requestPath is inside an "Issued" or +// "Received" subtree. The check is purely on path segments — a file +// named "Issued.txt" does not trigger WORM, but +// "/Project/Vendor/Issued/foo.pdf" does, as does +// "/Project/Vendor/Issued/" itself. requestPath may be a URL path +// ("/foo/bar") or a filesystem path; only segment names matter. +func IsWormPath(requestPath string) bool { + clean := strings.Trim(filepath.ToSlash(requestPath), "/") + if clean == "" { + return false + } + for _, seg := range strings.Split(clean, "/") { + for _, name := range WormFolderNames { + if seg == name { + return true + } + } + } + return false +} + +// 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 Issued/Received 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 +// Issued folder explicitly granting `_doc_controller: cr`. +func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC } + +// WormFolderLevelIndex returns the chain index of the deepest +// "Issued" or "Received" 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 (e.g. a request to a file inside an Issued +// folder has a chain that only covers up to the Issued directory, +// not the file itself). +// +// 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 { + clean := strings.Trim(filepath.ToSlash(requestPath), "/") + if clean == "" || numLevels <= 0 { + return -1 + } + deepest := -1 + for i, seg := range strings.Split(clean, "/") { + for _, name := range WormFolderNames { + if seg == name { + // URL segment i lives at chain index i+1 (root is index 0). + idx := i + 1 + if idx < numLevels && idx > deepest { + deepest = idx + } + } + } + } + return deepest +} diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go new file mode 100644 index 0000000..2c3d37c --- /dev/null +++ b/zddc/internal/zddc/special_test.go @@ -0,0 +1,76 @@ +package zddc + +import "testing" + +func TestIsAutoOwnParent(t *testing.T) { + yes := []string{"Incoming", "Working", "Staging"} + no := []string{"Issued", "Received", "incoming", "Random", "", "Working/sub"} + for _, n := range yes { + if !IsAutoOwnParent(n) { + t.Errorf("IsAutoOwnParent(%q) = false, want true", n) + } + } + for _, n := range no { + if IsAutoOwnParent(n) { + t.Errorf("IsAutoOwnParent(%q) = true, want false", n) + } + } +} + +func TestIsWormPath(t *testing.T) { + cases := map[string]bool{ + "": false, + "/": false, + "/Project/Issued": true, + "/Project/Issued/": true, + "/Project/Issued/file.pdf": true, + "/Project/Issued/sub/file.pdf": true, + "/Project/Vendor/Issued/x.pdf": true, + "/Project/Vendor/Received/y": true, + "/Project/Working/draft.md": false, + "/Project/Working/Issued.txt": false, // file named Issued.txt — not a path segment + "/Project/issued/lower.pdf": false, // lowercase ≠ Issued + } + for in, want := range cases { + if got := IsWormPath(in); got != want { + t.Errorf("IsWormPath(%q) = %v, want %v", in, got, want) + } + } +} + +func TestWormMaskStripsWDA(t *testing.T) { + rwcda, _ := ParseVerbSet("rwcda") + masked := WormMask(rwcda) + if got := masked.String(); got != "rc" { + t.Errorf("WormMask(rwcda) = %q, want rc", got) + } + + rw, _ := ParseVerbSet("rw") + if got := WormMask(rw).String(); got != "r" { + t.Errorf("WormMask(rw) = %q, want r", got) + } + + cd, _ := ParseVerbSet("cd") + if got := WormMask(cd).String(); got != "c" { + t.Errorf("WormMask(cd) = %q, want c", got) + } + + if got := WormMask(0).String(); got != "" { + t.Errorf("WormMask(0) = %q, want empty", got) + } +} + +func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) { + want := map[string]bool{ + "Incoming": false, "Working": false, "Staging": false, + "Issued": false, "Received": false, + } + for _, n := range SpecialFolderNames { + want[n] = true + } + for n, present := range want { + if !present { + t.Errorf("SpecialFolderNames missing %q", n) + } + } +}