feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders

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 <new>      → action c
  - PUT <existing> → 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-05 15:58:04 -05:00
parent 13ae1498e4
commit 3115e388fc
30 changed files with 3098 additions and 154 deletions

View file

@ -74,6 +74,16 @@ shared/
base.css CSS tokens and primitives included first by every tool's CSS build 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.js canonical filename/folder/revision parsers, formatters, status validation
zddc-filter.js shared ZDDC project/status filter UI module 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 theme.js light/dark theme switcher
help.js shared help dialog module help.js shared help dialog module
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp) build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)

View file

@ -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 | | 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/` | | 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 | | 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 | | 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_ROOT>/.zddc.d/logs/access-<host>.log` | | Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.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 ### 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 | | 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) | | 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 | | 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)) | | 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 | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) | | 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 | | 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 | | 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 | | 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 The full bullet list with NIST control references is in
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis." [`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` | `/<new-path>` | `If-Match: "<etag>"` (optional) | `c` | 201 created |
| `PUT` | `/<existing-path>` | `If-Match: "<etag>"` (optional) | `w` | 200 overwritten |
| `PUT` | `/<dir>/.zddc` | — | `a` | 200/201 |
| `DELETE` | `/<path>` | `If-Match: "<etag>"` (optional) | `d` | 204 |
| `POST` | `/<path>` | `X-ZDDC-Op: move` + `X-ZDDC-Destination: /new/path` | `w` (src) + `c` (dst) | 200 |
| `POST` | `/<path>/` | `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 ### Why the tool-rooted view matters for third-party containment
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool

View file

@ -35,6 +35,7 @@ concat_files \
"../shared/vendor/docx-preview.min.js" \ "../shared/vendor/docx-preview.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \

View file

@ -28,33 +28,85 @@
* Initialize the application * Initialize the application
*/ */
function init() { 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()) { if (!checkBrowserCompatibility()) {
showBrowserWarning(); showBrowserWarning();
return; return;
} }
// Cache DOM elements
cacheDOMElements();
// Set up event listeners
setupEventListeners();
// Show welcome screen
showWelcomeScreen(); 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() { function checkBrowserCompatibility() {
return 'showDirectoryPicker' in window; 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 =
'<h2 style="margin-bottom: 0.75rem;">No permission to list this directory</h2>' +
'<p>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.</p>';
screen.appendChild(msg);
var addBtn = document.getElementById('addDirectoryBtn');
if (addBtn) addBtn.disabled = true;
}
/** /**
* Show browser compatibility warning * Show browser compatibility warning
*/ */

View file

@ -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); const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
try { try {
// Get fresh handle for old file if (window.zddc.source.isHttpHandle(file.folderHandle)) {
const oldHandle = await file.folderHandle.getFileHandle(oldFilename); 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 // Read the file content
const fileData = await oldHandle.getFile(); const fileData = await oldHandle.getFile();
// Create new file with new name // Create new file with new name
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true }); const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
const writable = await newHandle.createWritable(); const writable = await newHandle.createWritable();
await writable.write(fileData); await writable.write(fileData);
await writable.close(); await writable.close();
// Delete old file // Delete old file
await file.folderHandle.removeEntry(oldFilename); await file.folderHandle.removeEntry(oldFilename);
// Update file handle
file.handle = newHandle;
// Update file handle
file.handle = newHandle;
}
} catch (err) { } catch (err) {
console.error(`Failed to rename file:`, err); console.error(`Failed to rename file:`, err);
throw err; throw err;

View file

@ -39,6 +39,7 @@ concat_files \
# JavaScript files to concatenate in order # JavaScript files to concatenate in order
concat_files \ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \

View file

@ -574,43 +574,48 @@ async function refreshDirectory() {
} }
/** /**
* Build a synthetic, read-only "file handle" backed by a URL. * Surface a clear "no permission to list this directory" message in
* Implements `getFile()` so the rest of the app (which only needs to read) * the file tree pane when the server returns 403 on the initial
* works without changes. Lacks `createWritable()` saveFile detects this * listing. Distinct from "host doesn't serve JSON" so the user
* and routes to a Save-As download. * understands why the tree is empty.
*/ */
function createServerFileHandle(name, url) { function showServerForbiddenMessage() {
let cached = null; const treeEl = document.getElementById('file-tree');
return { if (!treeEl) return;
kind: 'file', treeEl.innerHTML =
name, '<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
_serverUrl: url, '<strong>No permission to list this directory.</strong>' +
_readOnly: true, '<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
async getFile() { 'Contact the document controller if you believe this is wrong.</p>' +
if (cached) return cached; '</div>';
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;
},
};
} }
/** /**
* Build a synthetic directory handle (read-only) backed by a server URL. * Build a CRUD-capable file handle backed by a URL uses the shared
* Returned for nested entries so existing code paths that probe for `.handle` * HTTP polyfill from window.zddc.source. The polyfill's getFile() does
* still work; not currently used for traversal. * 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) { function createServerDirectoryHandle(name, url) {
return { const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
kind: 'directory', handle._serverUrl = url;
name, handle._readOnly = false;
_serverUrl: url, return handle;
_readOnly: true,
};
} }
/** /**
@ -686,8 +691,16 @@ async function loadServerDirectory() {
// listings (zddc-server / Caddy). On a plain static host the probe fails // 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 // and we must leave "Add Local Directory" visible so the user can still
// load local files. // 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 { try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (resp.status === 403) {
showServerForbiddenMessage();
return;
}
if (!resp.ok) return; if (!resp.ok) return;
const items = await resp.json(); const items = await resp.json();
if (!Array.isArray(items)) return; if (!Array.isArray(items)) return;
@ -710,13 +723,13 @@ async function loadServerDirectory() {
entries: {}, entries: {},
}; };
// Surface refresh, hide write-only controls. "Add Local Directory" // Surface refresh. The server now exposes a CRUD file API, so write
// stays visible (de-emphasized via btn--subtle) so the user can // controls (new file, save, delete) stay enabled — the polyfill
// switch to a local folder at any time. // 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'); const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden'); if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn'); const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) { if (addDirBtn) {
addDirBtn.classList.remove('btn-primary'); addDirBtn.classList.remove('btn-primary');

View file

@ -34,8 +34,8 @@ function createActionButtons(filePath, type) {
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'tree-actions'; actionsDiv.className = 'tree-actions';
// Server mode is read-only: no rename, delete, or new-file actions. // Server mode now supports full CRUD via the file API — drop the
if (serverSourceMode) return actionsDiv; // legacy short-circuit that hid the rename/delete/new-file actions.
if (type === 'directory') { if (type === 'directory') {
// Directory: + (new file) + ✕ (delete) // Directory: + (new file) + ✕ (delete)

368
shared/zddc-source.js Normal file
View file

@ -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 /<dir>/<tool>.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
};
})();

View file

@ -45,6 +45,7 @@ concat_files \
"../shared/vendor/docx-preview.min.js" \ "../shared/vendor/docx-preview.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \

View file

@ -1539,4 +1539,37 @@
filesModule.bindActionButtons(); filesModule.bindActionButtons();
filesModule.setupTableEditing(); filesModule.setupTableEditing();
}); });
// Auto-load when served by zddc-server: the page lives at
// /<...>/Staging/<folder>/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); })(window.transmittalApp);

View file

@ -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. when it fights you" below before designing a layout.
```yaml ```yaml
# Example .zddc file # Example .zddc — modern schema with verbs and roles
roles:
_company:
members: ["*@mycompany.com"]
_doc_controller:
members: [dc@mycompany.com]
acl: acl:
allow: permissions:
- "*@mycompany.com" # everyone at mycompany.com _company: r # everyone at mycompany.com gets read
- "contractor@partner.com" # specific external user _doc_controller: rwcda # doc controller gets full control
deny: "contractor@partner.com": rw # specific external — read + overwrite
- "intern@mycompany.com" # override: block this specific user "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` ### Step 1: starter `.zddc`
Every install should write a root `.zddc` before exposing the bind address. The 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 ### How a request is evaluated
When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along Each request carries an **action verb** (`r` for `GET`, `w` for `PUT` to an
the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest existing file, `c` for `PUT` to a new file or `mkdir`, `d` for `DELETE`, `a`
level first) looking for a match. The first explicit match wins — either an allow for writes to `.zddc`). zddc-server reads every `.zddc` along the chain from
or a deny. `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 1. **Admin bypass.** If the email is in the root `admins:` list (root admin) or
deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny any subtree-level `admins:` list on the chain (subtree admin), grant
beats allow — see anti-patterns below.)* `rwcda` and skip the cascade entirely.
2. **Same level**, check allow patterns. If the email matches → **allow**, stop 2. **At each level**, find every `permissions:` entry whose principal matches
walking. the user (direct email pattern, or role membership via `roles:` lookup).
3. **No match at this level** → walk up to the parent directory's `.zddc` and - If any matching entry has the empty verb set `""`**403 Forbidden**, stop.
repeat. - 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:** 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). - No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
- If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny). - At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
The two functions implementing this are `AllowedAtLevel` (within-level: deny first, Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`).
built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`.
#### 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 /<parent>/<new>/ X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: <email>` and `permissions: { <email>: 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
# /<vendor>/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 ### Glob patterns
@ -586,20 +666,21 @@ have to redo the gap analysis from scratch.
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream - **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
proxy. Required: documented reference deployment with PIV/CAC via proxy. Required: documented reference deployment with PIV/CAC via
oauth2-proxy or equivalent. oauth2-proxy or equivalent.
- **Role-based access control** (NIST AC-3(7)) — current model is per-email - ~~**Role-based access control** (NIST AC-3(7))~~*closed.* Roles are
allow/deny + a single root-admin role. Required: roles as first-class first-class entities defined under `roles:` in any `.zddc`, available
entities, `.zddc` syntax for role grants, identity-source-driven role at the level they're declared and all descendants. `acl.permissions`
assignment. grants verb sets (`r`/`w`/`c`/`d`/`a`) per role or per email pattern.
- **Least-privilege bounding** (NIST AC-6) — *partially complete.* Identity-source-driven role assignment plumbs through unchanged
Leaf-allow-overrides-parent-deny is the cascade's intentional (the upstream proxy still asserts the email; role membership is
delegation behavior in commercial mode and is preserved in the evaluated server-side against the cascade).
internal Go evaluator. For federal deployments, `--print-rego=federal` - ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators
emits a parity-tested Rego policy where parent denies are absolute; set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still switch the in-process Go evaluator into the federal posture: any
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`) ancestor explicit-deny is absolute and cannot be overridden by a
that switches the in-process Go evaluator's semantics without leaf grant. The mode is logged at startup and surfaced on
requiring an OPA sidecar — currently federal-mode is reachable only `/.profile/config`. The legacy commercial behavior is preserved as
via the external-OPA path. 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 - **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required: authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity documented integration with at least one IdP supporting federal identity

View file

@ -135,9 +135,10 @@ func main() {
// http(s):// or unix:// values send each decision to an external // http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies). // OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{ deciderCfg := policy.Config{
URL: cfg.OPAURL, URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen, FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL, CacheTTL: cfg.OPACacheTTL,
CascadeMode: cfg.CascadeMode,
} }
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is // Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper"). // the policy package's sentinel for "skip the wrapper").
@ -152,7 +153,8 @@ func main() {
slog.Info("policy decider ready", slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL), "mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL, "url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL) "cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode)
// Innermost handler: dispatch. // Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 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 // Apps resolution for the root landing path: GET / or /index.html with
// no real index.html on disk → serve via apps.Serve("landing"). The // no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch // other four apps are caught by the "stat fails → app HTML?" branch

View file

@ -215,6 +215,76 @@ func TestDispatchAppsResolution(t *testing.T) {
// import even when we trim test cases later. // import even when we trim test cases later.
var _ = apps.DefaultUpstream 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) { func mustMkdir(t *testing.T, path string) {
t.Helper() t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil { if err := os.MkdirAll(path, 0o755); err != nil {

View file

@ -3,13 +3,19 @@ package apps
import ( import (
"path/filepath" "path/filepath"
"strings" "strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// Folder name conventions that gate which tools are virtually available // Folder name conventions that gate which tools are virtually available
// at a given path. The names are case-sensitive; ZDDC convention uses // 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 ( 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"} folderNamesWorking = []string{"Working"}
folderNamesStaging = []string{"Staging"} folderNamesStaging = []string{"Staging"}
) )

View file

@ -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) 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. 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. 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 // 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.") "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"), 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.") "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"), accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+ "Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+ "Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -148,6 +154,8 @@ func Load(args []string) (Config, error) {
OPAFailOpen: *opaFailOpenFlag, OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag, OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag, AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
} }
// Default Root to the current working directory. // 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") 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 // Plain HTTP mode trusts the email header from any client. Only safe
// behind an authenticating reverse proxy. Refuse to start when binding // behind an authenticating reverse proxy. Refuse to start when binding
// plain HTTP to a non-loopback interface unless the operator has // 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 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
}

View file

@ -42,7 +42,10 @@ func CORSMiddleware(cfg config.Config, next http.Handler) http.Handler {
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-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") h.Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return

View file

@ -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 /<path> 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 /<path> remove a file. Optional If-Match. Refuses to
// delete directories or hidden paths.
// POST /<path> 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 /<path>/. 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...)
}

View file

@ -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())
}
}

View file

@ -58,6 +58,13 @@ func EmailFromContext(r *http.Request) string {
return "" 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 // DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was // context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and // installed — this matches the "no OPA configured" semantics and

View file

@ -193,6 +193,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath string `json:"index_path"` IndexPath string `json:"index_path"`
EmailHeader string `json:"email_header"` EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"` CORSOrigins []string `json:"cors_origins"`
CascadeMode string `json:"cascade_mode"`
} }
writeJSON(w, response{ writeJSON(w, response{
Root: cfg.Root, Root: cfg.Root,
@ -204,6 +205,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath: cfg.IndexPath, IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader, EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins, CORSOrigins: cfg.CORSOrigins,
CascadeMode: cfg.CascadeMode,
}) })
} }

View file

@ -58,18 +58,55 @@ import (
// External Rego policies can: // External Rego policies can:
// - read input.user.email (string) // - read input.user.email (string)
// - read input.path (string) // - read input.path (string)
// - read input.action ("read" | "write"); empty/absent ≡ "read"
// - walk input.policy_chain.levels[].acl.{allow,deny} for // - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved // custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the // input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally. // 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 { type AllowInput struct {
User struct { User struct {
Email string `json:"email"` Email string `json:"email"`
} `json:"user"` } `json:"user"`
Path string `json:"path"` Path string `json:"path"`
Action string `json:"action,omitempty"`
PolicyChain *SerializableChain `json:"policy_chain,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. // SerializableChain is a JSON-friendly view of zddc.PolicyChain.
// We don't tag zddc.PolicyChain itself because it's tightly coupled // We don't tag zddc.PolicyChain itself because it's tightly coupled
// to the parser; the duplication is one struct. // to the parser; the duplication is one struct.
@ -92,6 +129,13 @@ type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path" URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny 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. 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. // New constructs a Decider per cfg.URL semantics.
@ -106,8 +150,9 @@ type Config struct {
// //
// Returns an error if URL is unrecognized. // Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) { func New(cfg Config) (Decider, error) {
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") { if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil return &InternalDecider{Mode: mode}, nil
} }
var inner Decider var inner Decider
var err error var err error
@ -140,10 +185,16 @@ func New(cfg Config) (Decider, error) {
return &cachingDecider{inner: inner, ttl: ttl}, nil return &cachingDecider{inner: inner, ttl: ttl}, nil
} }
// InternalDecider routes Allow through zddc.AllowedWithChain. No // InternalDecider routes Allow through zddc.AllowedAction with the
// network, no Rego, no new dependencies — same Go evaluator the // configured cascade mode and applies the Issued/Received WORM mask
// existing test suite covers. // post-decision. No network, no Rego, no new dependencies.
type InternalDecider struct{} //
// 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) { func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{} chain := zddc.PolicyChain{}
@ -151,7 +202,28 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
chain.Levels = input.PolicyChain.Levels chain.Levels = input.PolicyChain.Levels
chain.HasAnyFile = input.PolicyChain.HasAnyFile 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 // 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 // AllowFromChain is a convenience for callers that already have a
// PolicyChain in hand. Equivalent to constructing AllowInput manually // 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) { 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 in.User.Email = email
return d.Allow(ctx, in) return d.Allow(ctx, in)
} }

View file

@ -2,38 +2,165 @@ package zddc
import "strings" import "strings"
// AllowedAtLevel checks whether email is explicitly allowed or denied by a single // AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
// .zddc level. Returns (decision, matched): // callers that only need the legacy boolean read decision. New code
// - (false, true) — email matched a deny pattern → deny // should call GrantedVerbsAtLevel directly.
// - (true, true) — email matched an allow pattern → allow
// - (false, false) — no match in this level → keep walking up
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) { func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
// deny checked first chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
for _, pattern := range level.ACL.Deny { v, m := GrantedVerbsAtLevel(chain, 0, email)
if MatchesPattern(pattern, email) { if !m {
return false, true return false, false
}
} }
for _, pattern := range level.ACL.Allow { return v.Has(VerbR), true
if MatchesPattern(pattern, email) {
return true, true
}
}
return false, false
} }
// AllowedWithChain evaluates a PolicyChain bottom-up (deepest level first). // GrantedVerbsAtLevel computes the verb set granted to email at
// First explicit match (allow or deny) wins. // chain.Levels[levelIdx]. Returns (set, matched):
// If no level matches and HasAnyFile is false → allow (no rules = public). // - matched=false → no entry in this level matches the user; cascade walks on
// If no level matches and HasAnyFile is true → deny (user not on any list). // - 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 { func AllowedWithChain(chain PolicyChain, email string) bool {
for i := len(chain.Levels) - 1; i >= 0; i-- { return AllowedAction(chain, email, VerbR, ModeDelegated)
decision, matched := AllowedAtLevel(chain.Levels[i], email) }
if matched {
return decision // 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. // MatchesPattern checks if email matches a glob pattern.

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -6,14 +6,43 @@ import (
"gopkg.in/yaml.v3" "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 // JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical // in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries. // in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct { type ACLRules struct {
Allow []string `yaml:"allow" json:"allow,omitempty"` Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
Deny []string `yaml:"deny" json:"deny,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. // ZddcFile represents the parsed contents of a .zddc configuration file.
@ -54,6 +83,17 @@ type ZddcFile struct {
Title string `yaml:"title" json:"title,omitempty"` Title string `yaml:"title" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,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. // 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 { if err := yaml.Unmarshal(data, &zf); err != nil {
return ZddcFile{}, err return ZddcFile{}, err
} }
mergeLegacyACL(&zf.ACL)
return zf, nil 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] = ""
}
}
}

191
zddc/internal/zddc/roles.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}
}