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
zddc.js canonical filename/folder/revision parsers, formatters, status validation
zddc-filter.js shared ZDDC project/status filter UI module
zddc-source.js HTTP source abstraction — FS Access API polyfill (HttpDirectoryHandle,
HttpFileHandle) backed by zddc-server's listing JSON + file API
(PUT/DELETE/POST). Tools that auto-load the current dir in HTTP mode
call window.zddc.source.detectServerRoot() at init. The probe
returns { handle, status }: status 200 → use handle; 403 → user
lacks `r` on this directory (show "no permission to list"
message); 0 → not http(s) or non-zddc-server. Tools must
handle the 403 case so a permission-locked path doesn't
silently render as an empty welcome screen.
hash.js SHA-256 helpers used by the file API + classifier hashes
theme.js light/dark theme switcher
help.js shared help dialog module
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)

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 |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_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
@ -478,8 +480,8 @@ whether to deploy the system should know which column they're in.
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) |
| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) |
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) |
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
@ -488,6 +490,53 @@ whether to deploy the system should know which column they're in.
The full bullet list with NIST control references is in
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
### Permission model: roles + verbs
Five permission verbs gate every read and write:
| Verb | Allows |
|---|---|
| `r` | read file bytes; list directory |
| `w` | overwrite an existing file; rename existing file |
| `c` | create a new file or directory |
| `d` | delete a file |
| `a` | modify the ACL of this subtree (write `.zddc`) |
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny``""`), so existing deployments behave identically.
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`:
- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny.
- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant.
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
#### Special folders
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents.
The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.
### File API (authenticated CRUD)
zddc-server exposes write methods on the same URL space as GET. Each method maps to a specific verb and is gated against the cascade-derived verb set:
| Method | URL | Headers | Action verb | Status |
|---|---|---|---|---|
| `PUT` | `/<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
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/zddc.js" \
"../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/app.js" \

View file

@ -28,33 +28,85 @@
* Initialize the application
*/
function init() {
// Cache DOM elements + wire events first so the welcome screen
// (and the HTTP-mode auto-load below) can use them.
cacheDOMElements();
setupEventListeners();
// Browser-compatibility branch:
// HTTP mode (served by zddc-server) — works everywhere; the
// HTTP polyfill stands in for the FS Access API. Auto-load
// the directory the page lives in.
// Local mode (file://) — requires FS Access API for write
// access to the user-picked folder. Show the warning if
// the API is missing.
if (location.protocol === 'http:' || location.protocol === 'https:') {
// Don't disable the picker button — even in HTTP mode the
// user might want to add a local folder. But the auto-load
// below means the welcome screen usually never shows.
(async function () {
try {
var probe = await window.zddc.source.detectServerRoot();
if (probe.handle) {
await openDirectory(probe.handle);
return;
}
if (probe.status === 403) {
showHttpForbiddenMessage();
return;
}
} catch (err) {
console.warn('classifier: server-mode auto-load failed:', err);
}
// Server-mode probe inconclusive — fall through to welcome.
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
showWelcomeScreen();
})();
return;
}
// Check browser compatibility
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
// Cache DOM elements
cacheDOMElements();
// Set up event listeners
setupEventListeners();
// Show welcome screen
showWelcomeScreen();
}
/**
* Check if browser supports File System Access API
* Check if browser supports File System Access API. Used in local
* (file://) mode only — HTTP mode runs through the HTTP polyfill,
* which has no browser dependency beyond fetch.
*/
function checkBrowserCompatibility() {
return 'showDirectoryPicker' in window;
}
/**
* Show a clear "no permission to list" message for HTTP-mode users
* who land on a path their ACL doesn't allow them to list. Distinct
* from the welcome screen so the user understands why the file tree
* is empty rather than wondering if they need to pick a folder.
*/
function showHttpForbiddenMessage() {
var screen = document.getElementById('welcomeScreen');
if (!screen) return;
screen.classList.remove('hidden');
var msg = document.createElement('div');
msg.className = 'classifier-forbidden-message';
msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;';
msg.innerHTML =
'<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
*/

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

View file

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

View file

@ -574,43 +574,48 @@ async function refreshDirectory() {
}
/**
* Build a synthetic, read-only "file handle" backed by a URL.
* Implements `getFile()` so the rest of the app (which only needs to read)
* works without changes. Lacks `createWritable()` saveFile detects this
* and routes to a Save-As download.
* Surface a clear "no permission to list this directory" message in
* the file tree pane when the server returns 403 on the initial
* listing. Distinct from "host doesn't serve JSON" so the user
* understands why the tree is empty.
*/
function createServerFileHandle(name, url) {
let cached = null;
return {
kind: 'file',
name,
_serverUrl: url,
_readOnly: true,
async getFile() {
if (cached) return cached;
const resp = await fetch(url, { cache: 'no-cache' });
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
const lastMod = resp.headers.get('Last-Modified');
const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
const blob = await resp.blob();
cached = new File([blob], name, { type: blob.type, lastModified });
return cached;
},
};
function showServerForbiddenMessage() {
const treeEl = document.getElementById('file-tree');
if (!treeEl) return;
treeEl.innerHTML =
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
'<strong>No permission to list this directory.</strong>' +
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
'Contact the document controller if you believe this is wrong.</p>' +
'</div>';
}
/**
* Build a synthetic directory handle (read-only) backed by a server URL.
* Returned for nested entries so existing code paths that probe for `.handle`
* still work; not currently used for traversal.
* Build a CRUD-capable file handle backed by a URL uses the shared
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
* a GET, and createWritable() PUTs bytes back (file API on zddc-server).
*
* Adds `_serverUrl` for legacy code paths that still expect that field.
* Marks `_readOnly: false` so editor.js leaves save buttons enabled.
*/
function createServerFileHandle(name, url) {
const handle = new window.zddc.source.HttpFileHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
* Build a CRUD-capable directory handle backed by a server URL uses
* the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
* getDirectoryHandle({create}), and removeEntry() against the server
* file API. _serverUrl/_readOnly are kept for legacy probes.
*/
function createServerDirectoryHandle(name, url) {
return {
kind: 'directory',
name,
_serverUrl: url,
_readOnly: true,
};
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
@ -686,8 +691,16 @@ async function loadServerDirectory() {
// listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Add Local Directory" visible so the user can still
// load local files.
//
// 403 means the host is a zddc-server but the user lacks `r` on this
// directory (a "no list" permission posture). Show a clear message so
// the user understands why the tree is empty.
try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (resp.status === 403) {
showServerForbiddenMessage();
return;
}
if (!resp.ok) return;
const items = await resp.json();
if (!Array.isArray(items)) return;
@ -710,13 +723,13 @@ async function loadServerDirectory() {
entries: {},
};
// Surface refresh, hide write-only controls. "Add Local Directory"
// stays visible (de-emphasized via btn--subtle) so the user can
// switch to a local folder at any time.
// Surface refresh. The server now exposes a CRUD file API, so write
// controls (new file, save, delete) stay enabled — the polyfill
// routes their writes through PUT/DELETE/POST. "Add Local Directory"
// is de-emphasized so the user can still load a local folder if they
// want, but server-mode is now the default working mode.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');

View file

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

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/zddc.js" \
"../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/app.js" \

View file

@ -1539,4 +1539,37 @@
filesModule.bindActionButtons();
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);

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.
```yaml
# Example .zddc file
# Example .zddc — modern schema with verbs and roles
roles:
_company:
members: ["*@mycompany.com"]
_doc_controller:
members: [dc@mycompany.com]
acl:
allow:
- "*@mycompany.com" # everyone at mycompany.com
- "contractor@partner.com" # specific external user
deny:
- "intern@mycompany.com" # override: block this specific user
permissions:
_company: r # everyone at mycompany.com gets read
_doc_controller: rwcda # doc controller gets full control
"contractor@partner.com": rw # specific external — read + overwrite
"intern@mycompany.com": "" # explicit deny (empty verb set)
# Legacy form below still works — equivalent to the new form, with
# allow → "rwcd" and deny → "" entries auto-merged into permissions.
# acl:
# allow: ["*@mycompany.com", "contractor@partner.com"]
# deny: ["intern@mycompany.com"]
```
### Permission verbs
Every access decision resolves to a verb set drawn from `r/w/c/d/a`:
| Verb | Allows |
|---|---|
| `r` | read file bytes; list directory |
| `w` | overwrite an existing file; rename existing file |
| `c` | create a new file or directory |
| `d` | delete a file |
| `a` | modify the ACL of this subtree (write `.zddc`) |
The verb set is written as concatenated lowercase letters in canonical order — `""` (none / explicit deny), `r`, `cr`, `rwcd`, `rwcda`. Common archetypes:
- **`r`** — read-only (typical company default).
- **`cr`** — append-only / drop-box (the doc controller in `Issued`/`Received`: can file new documents, cannot overwrite or delete).
- **`rwcd`** — full content control without the right to change the ACL (vendor inside their working subtree).
- **`rwcda`** — full control including the ability to grant access to others (subtree creator; project owner).
### Roles
`roles:` defines named principal groups, available at the level they're declared and all descendants:
```yaml
roles:
vendor_acme:
members: ["*@acme.com"]
_doc_controller:
members: [dc@mycompany.com, alice@mycompany.com]
```
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work).
### Step 1: starter `.zddc`
Every install should write a root `.zddc` before exposing the bind address. The
@ -187,26 +231,62 @@ that need them. (See worked examples below.)
### How a request is evaluated
When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along
the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest
level first) looking for a match. The first explicit match wins — either an allow
or a deny.
Each request carries an **action verb** (`r` for `GET`, `w` for `PUT` to an
existing file, `c` for `PUT` to a new file or `mkdir`, `d` for `DELETE`, `a`
for writes to `.zddc`). zddc-server reads every `.zddc` along the chain from
`ZDDC_ROOT` down to the request directory, then walks **leaf → root** looking
for a level whose `acl.permissions` map matches the user.
1. **At the current level**, check deny patterns first. If the email matches any
deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny
beats allow — see anti-patterns below.)*
2. **Same level**, check allow patterns. If the email matches → **allow**, stop
walking.
3. **No match at this level** → walk up to the parent directory's `.zddc` and
repeat.
1. **Admin bypass.** If the email is in the root `admins:` list (root admin) or
any subtree-level `admins:` list on the chain (subtree admin), grant
`rwcda` and skip the cascade entirely.
2. **At each level**, find every `permissions:` entry whose principal matches
the user (direct email pattern, or role membership via `roles:` lookup).
- If any matching entry has the empty verb set `""`**403 Forbidden**, stop.
- Otherwise, take the **union** of matching verb sets at this level. If the
union is non-empty, the level "wins" — the requested verb is allowed iff
it's present in the union. Stop walking.
3. **No match at this level** → walk up to the parent directory's `.zddc`.
4. **No level matched anywhere in the chain:**
- If no `.zddc` file existed anywhere in the chain (`HasAnyFile=false`) → **allow** (the empty-tree default; see warning above).
- If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
- No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
- At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
The two functions implementing this are `AllowedAtLevel` (within-level: deny first,
then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first
walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is
built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`.
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`).
#### Cascade mode
The leaf-overrides-ancestor behavior above is the default — it's the historical
commercial-tenant model where a subtree owner can grant access without
root-admin involvement. Federal deployments needing absolute parent denies
(NIST AC-6) start the server with `--cascade-mode=strict` (or
`ZDDC_CASCADE_MODE=strict`):
- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny.
- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any
matching explicit-deny; if found, denied (subject to root-admin bypass).
Second pass is the leaf→root grant walk above. An ancestor explicit-deny
cannot be overridden by any leaf grant.
The mode is logged at startup and surfaced on `/.profile/config`. Subtree
`.zddc` files cannot change the mode — it's a deployment-wide policy.
#### Special folders
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /<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
@ -586,20 +666,21 @@ have to redo the gap analysis from scratch.
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
proxy. Required: documented reference deployment with PIV/CAC via
oauth2-proxy or equivalent.
- **Role-based access control** (NIST AC-3(7)) — current model is per-email
allow/deny + a single root-admin role. Required: roles as first-class
entities, `.zddc` syntax for role grants, identity-source-driven role
assignment.
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
Leaf-allow-overrides-parent-deny is the cascade's intentional
delegation behavior in commercial mode and is preserved in the
internal Go evaluator. For federal deployments, `--print-rego=federal`
emits a parity-tested Rego policy where parent denies are absolute;
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
that switches the in-process Go evaluator's semantics without
requiring an OPA sidecar — currently federal-mode is reachable only
via the external-OPA path.
- ~~**Role-based access control** (NIST AC-3(7))~~*closed.* Roles are
first-class entities defined under `roles:` in any `.zddc`, available
at the level they're declared and all descendants. `acl.permissions`
grants verb sets (`r`/`w`/`c`/`d`/`a`) per role or per email pattern.
Identity-source-driven role assignment plumbs through unchanged
(the upstream proxy still asserts the email; role membership is
evaluated server-side against the cascade).
- ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators
set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to
switch the in-process Go evaluator into the federal posture: any
ancestor explicit-deny is absolute and cannot be overridden by a
leaf grant. The mode is logged at startup and surfaced on
`/.profile/config`. The legacy commercial behavior is preserved as
the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains
available for org-specific Rego on top of this.
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity

View file

@ -135,9 +135,10 @@ func main() {
// http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
CascadeMode: cfg.CascadeMode,
}
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper").
@ -152,7 +153,8 @@ func main() {
slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL)
"cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode)
// Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -487,6 +489,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// File API — authenticated CRUD over the served tree. Catches PUT,
// DELETE, and POST on any non-reserved path. Read methods (GET/HEAD)
// fall through to the static / apps / directory pipeline below.
// Forms and .profile/.archive POSTs are already routed above this
// point so they take precedence.
if handler.IsWriteMethod(r.Method) {
handler.ServeFileAPI(cfg, w, r)
return
}
// Apps resolution for the root landing path: GET / or /index.html with
// no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch

View file

@ -215,6 +215,76 @@ func TestDispatchAppsResolution(t *testing.T) {
// import even when we trim test cases later.
var _ = apps.DefaultUpstream
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
withEmail := func(req *http.Request, email string) *http.Request {
// dispatch reads email from context (ACLMiddleware would normally
// set it), so set it directly here.
return req.WithContext(handler.WithEmail(req.Context(), email))
}
// PUT a new file via dispatch.
body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
}
// MOVE it.
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com")
req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
}
// DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
}
// Reserved segment guard still applies to writes.
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
}
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {

View file

@ -3,13 +3,19 @@ package apps
import (
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Folder name conventions that gate which tools are virtually available
// at a given path. The names are case-sensitive; ZDDC convention uses
// the capitalized forms.
// the capitalized forms. The full canonical list lives in
// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls
// the relevant subsets from there to avoid duplication.
var (
folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"}
// Subset of zddc.AutoOwnFolderNames where classifier is virtually
// available (the same three folders that grant mkdir auto-ownership).
folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames
folderNamesWorking = []string{"Working"}
folderNamesStaging = []string{"Staging"}
)

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)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -91,6 +93,10 @@ func Load(args []string) (Config, error) {
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024),
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"),
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -148,6 +154,8 @@ func Load(args []string) (Config, error) {
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
}
// Default Root to the current working directory.
@ -216,6 +224,15 @@ func Load(args []string) (Config, error) {
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
}
switch cfg.CascadeMode {
case "", "delegated":
cfg.CascadeMode = "delegated"
case "strict":
// ok
default:
return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode)
}
// Plain HTTP mode trusts the email header from any client. Only safe
// behind an authenticating reverse proxy. Refuse to start when binding
// plain HTTP to a non-loopback interface unless the operator has
@ -340,3 +357,16 @@ func parseDurationOrDefault(s string, def time.Duration) time.Duration {
}
return def
}
// parseInt64OrDefault parses a base-10 int64. Returns def on empty input
// or parse error.
func parseInt64OrDefault(s string, def int64) int64 {
if s == "" {
return def
}
var n int64
if _, err := fmt.Sscan(s, &n); err == nil {
return n
}
return def
}

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 != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
}
h.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
h.Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, POST, OPTIONS")
// Expose ETag and the file API's source / destination headers
// so cross-origin clients can read them after a write.
h.Set("Access-Control-Expose-Headers", "ETag, X-ZDDC-Source, X-ZDDC-Destination")
h.Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return

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 ""
}
// WithEmail returns a context carrying email under EmailKey. Test seam
// for handlers that look up the authenticated user via EmailFromContext;
// production traffic gets the same value injected by ACLMiddleware.
func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, EmailKey, email)
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and

View file

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

View file

@ -58,18 +58,55 @@ import (
// External Rego policies can:
// - read input.user.email (string)
// - read input.path (string)
// - read input.action ("read" | "write"); empty/absent ≡ "read"
// - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally.
//
// Action distinguishes read (GET/HEAD on listings, files, app HTML)
// from write (PUT, DELETE, POST/move on the file API). The internal
// decider treats both identically — any allow grants full CRUD,
// matching the model in place before the file API existed (anyone
// with read access also had OS-level write via the mounted share).
// External Rego policies can split the two by inspecting input.action.
type AllowInput struct {
User struct {
Email string `json:"email"`
} `json:"user"`
Path string `json:"path"`
Action string `json:"action,omitempty"`
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
}
// Action constants used in AllowInput.Action. Empty string is also
// accepted for back-compat with callers that don't specify a verb.
const (
ActionRead = "read" // listing + reading file bytes
ActionWrite = "write" // overwriting an existing file (legacy alias for the historical write-vs-read split)
ActionCreate = "create" // creating a new file or directory
ActionDelete = "delete" // deleting a file
ActionAdmin = "admin" // modifying ACL / .zddc / role definitions
)
// actionVerb maps an Action string to the zddc.VerbSet bit it requires.
// Returns the read verb for unrecognized values so the internal
// decider stays restrictive on unknown action labels.
func actionVerb(action string) zddc.VerbSet {
switch action {
case ActionWrite:
return zddc.VerbW
case ActionCreate:
return zddc.VerbC
case ActionDelete:
return zddc.VerbD
case ActionAdmin:
return zddc.VerbA
default:
return zddc.VerbR
}
}
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
// We don't tag zddc.PolicyChain itself because it's tightly coupled
// to the parser; the duplication is one struct.
@ -92,6 +129,13 @@ type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
// CascadeMode controls how the InternalDecider walks the ACL chain:
// "delegated" (default — leaf grants override ancestor denies) or
// "strict" (ancestor explicit-deny is absolute; NIST AC-6).
// External deciders ignore this — Rego policies access the chain
// directly and implement either semantic themselves.
CascadeMode string
}
// New constructs a Decider per cfg.URL semantics.
@ -106,8 +150,9 @@ type Config struct {
//
// Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) {
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil
return &InternalDecider{Mode: mode}, nil
}
var inner Decider
var err error
@ -140,10 +185,16 @@ func New(cfg Config) (Decider, error) {
return &cachingDecider{inner: inner, ttl: ttl}, nil
}
// InternalDecider routes Allow through zddc.AllowedWithChain. No
// network, no Rego, no new dependencies — same Go evaluator the
// existing test suite covers.
type InternalDecider struct{}
// InternalDecider routes Allow through zddc.AllowedAction with the
// configured cascade mode and applies the Issued/Received WORM mask
// post-decision. No network, no Rego, no new dependencies.
//
// The decider does NOT consult the admins:/IsAdmin escape hatch —
// callers in the handler package wire IsAdmin / IsSubtreeAdmin around
// the decision. Admins bypass the WORM mask there as well.
type InternalDecider struct {
Mode zddc.CascadeMode
}
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{}
@ -151,7 +202,28 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
chain.Levels = input.PolicyChain.Levels
chain.HasAnyFile = input.PolicyChain.HasAnyFile
}
return zddc.AllowedWithChain(chain, input.User.Email), nil
verb := actionVerb(input.Action)
email := input.User.Email
// WORM split: in Issued/Received, ancestor grants are read-only;
// only an explicit .zddc placed at-or-below the WORM folder can
// restore `c` (write-once) for principals it names. Admins are
// excluded from this code path by callers (handler package does
// the IsAdmin / IsSubtreeAdmin bypass before invoking Allow).
//
// EffectiveVerbsRange (rather than slicing chain.Levels) keeps the
// FULL chain visible to role-membership lookups so an ancestor's
// role definition still applies inside the sub-range walk.
if zddc.IsWormPath(input.Path) {
wormIdx := zddc.WormFolderLevelIndex(input.Path, len(chain.Levels))
if wormIdx >= 0 {
grantAbove := zddc.EffectiveVerbsRange(chain, 0, wormIdx, email, d.Mode) & zddc.VerbR
grantBelow := zddc.EffectiveVerbsRange(chain, wormIdx, len(chain.Levels), email, d.Mode) & zddc.VerbsRC
return (grantAbove | grantBelow).Has(verb), nil
}
}
return zddc.AllowedAction(chain, email, verb, d.Mode), nil
}
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
@ -240,9 +312,29 @@ func (d *HTTPDecider) failResult(err error) (bool, error) {
// AllowFromChain is a convenience for callers that already have a
// PolicyChain in hand. Equivalent to constructing AllowInput manually
// from (chain, email, path) and calling d.Allow.
// from (chain, email, path) and calling d.Allow. Implies "read".
//
// New callers should use AllowActionFromChain with an explicit verb so
// the audit/policy stream records intent and the internal decider can
// apply the right verb-specific check.
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)}
return AllowActionFromChain(ctx, d, chain, email, path, ActionRead)
}
// AllowWriteFromChain is the legacy write-action helper. Newer callers
// should pick the specific verb (ActionCreate / ActionWrite /
// ActionDelete / ActionAdmin) via AllowActionFromChain instead.
func AllowWriteFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
return AllowActionFromChain(ctx, d, chain, email, path, ActionWrite)
}
// AllowActionFromChain is the canonical access-decision helper.
// External Rego policies can branch on input.action to differentiate
// among the five verbs (read / write / create / delete / admin). The
// internal decider maps each action to its zddc.VerbSet bit and walks
// the cascade in the configured mode (delegated / strict).
func AllowActionFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path, action string) (bool, error) {
in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)}
in.User.Email = email
return d.Allow(ctx, in)
}

View file

@ -2,38 +2,165 @@ package zddc
import "strings"
// AllowedAtLevel checks whether email is explicitly allowed or denied by a single
// .zddc level. Returns (decision, matched):
// - (false, true) — email matched a deny pattern → deny
// - (true, true) — email matched an allow pattern → allow
// - (false, false) — no match in this level → keep walking up
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
// callers that only need the legacy boolean read decision. New code
// should call GrantedVerbsAtLevel directly.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
// deny checked first
for _, pattern := range level.ACL.Deny {
if MatchesPattern(pattern, email) {
return false, true
}
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email)
if !m {
return false, false
}
for _, pattern := range level.ACL.Allow {
if MatchesPattern(pattern, email) {
return true, true
}
}
return false, false
return v.Has(VerbR), true
}
// AllowedWithChain evaluates a PolicyChain bottom-up (deepest level first).
// First explicit match (allow or deny) wins.
// If no level matches and HasAnyFile is false → allow (no rules = public).
// If no level matches and HasAnyFile is true → deny (user not on any list).
// GrantedVerbsAtLevel computes the verb set granted to email at
// chain.Levels[levelIdx]. Returns (set, matched):
// - matched=false → no entry in this level matches the user; cascade walks on
// - matched=true, set={} → an entry matched with the empty verb set; explicit deny
// - matched=true, set!={} → union of verb sets from every matching entry
//
// Role lookups for principal keys without "@" use RoleMembers, which
// walks levelIdx → root for the closest definition.
//
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
// parse time) so this function works correctly on test-constructed
// ZddcFile literals as well as parser output.
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false
}
level := chain.Levels[levelIdx]
perms := effectivePermissions(level.ACL)
if len(perms) == 0 {
return 0, false
}
matched := false
deniedExplicit := false
var grant VerbSet
for principal, verbStr := range perms {
if !MatchesPrincipal(principal, email, chain, levelIdx) {
continue
}
matched = true
v, _ := ParseVerbSet(verbStr) // unknown letters silently dropped
if verbStr == "" {
deniedExplicit = true
continue
}
grant = grant.Union(v)
}
if !matched {
return 0, false
}
if deniedExplicit {
// Empty-set match wins over any grant entries at the same level —
// explicit deny is always more specific than a permissive role
// membership at the same scope.
return 0, true
}
return grant, true
}
// effectivePermissions returns the union of acl.permissions and the
// legacy acl.allow / acl.deny fields, with permissions winning on
// collision. Returns nil if all three are empty. Does not mutate rules.
func effectivePermissions(rules ACLRules) map[string]string {
if len(rules.Permissions) == 0 && len(rules.Allow) == 0 && len(rules.Deny) == 0 {
return nil
}
out := make(map[string]string, len(rules.Permissions)+len(rules.Allow)+len(rules.Deny))
for _, pat := range rules.Allow {
out[pat] = "rwcd"
}
for _, pat := range rules.Deny {
out[pat] = ""
}
for k, v := range rules.Permissions {
out[k] = v
}
return out
}
// AllowedWithChain evaluates a PolicyChain leaf→root (deepest level first)
// for the read action. Preserved for legacy callers and existing read paths
// that haven't migrated to AllowedAction yet.
func AllowedWithChain(chain PolicyChain, email string) bool {
for i := len(chain.Levels) - 1; i >= 0; i-- {
decision, matched := AllowedAtLevel(chain.Levels[i], email)
if matched {
return decision
return AllowedAction(chain, email, VerbR, ModeDelegated)
}
// AllowedAction evaluates a PolicyChain for a specific verb and cascade mode.
// Thin wrapper around EffectiveVerbs that surfaces the boolean answer.
func AllowedAction(chain PolicyChain, email string, verb VerbSet, mode CascadeMode) bool {
return EffectiveVerbs(chain, email, mode).Has(verb)
}
// EffectiveVerbs computes the verb set granted to email by the cascade.
// Walks the full chain and applies the default-allow rule (no .zddc
// anywhere → public access).
func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet {
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode)
if v == 0 && !chain.HasAnyFile {
// Public-tree default: empty chain with no .zddc files anywhere
// → grant everything. EffectiveVerbsRange returns 0 in this
// case because it has no opinion on default semantics outside
// a sub-range walk; the full-chain wrapper applies the rule.
return VerbAll
}
return v
}
// EffectiveVerbsRange computes the verb set granted by walking only
// chain.Levels[fromIdx:toIdx] for matching permission entries. Role
// definitions are still looked up over the FULL chain via
// GrantedVerbsAtLevel → MatchesPrincipal → lookupRoleMembers, so an
// ancestor's role definition remains visible to a sub-range walk.
//
// Used by the WORM split: above-the-WORM-folder and at-or-below-the-
// WORM-folder are evaluated as separate ranges, then their grants are
// masked and unioned.
//
// Cascade mode controls whether ancestor explicit-denies are absolute
// (Strict) or can be overridden by a leaf grant (Delegated). The
// strict-mode pass is restricted to the same range — splitting the
// chain implies splitting the strict-mode walk too.
//
// This function does NOT consult the admins:/IsAdmin escape hatch and
// does NOT apply the Issued/Received WORM mask.
func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mode CascadeMode) VerbSet {
if fromIdx < 0 {
fromIdx = 0
}
if toIdx > len(chain.Levels) {
toIdx = len(chain.Levels)
}
if fromIdx >= toIdx {
// Empty range — no levels to consult. Caller is responsible
// for the default-deny semantics in this case (typically the
// caller has another range to combine with).
return 0
}
if mode == ModeStrict {
for i := fromIdx; i < toIdx; i++ {
grant, matched := GrantedVerbsAtLevel(chain, i, email)
if matched && grant == 0 {
return 0
}
}
}
return !chain.HasAnyFile
for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email)
if !matched {
continue
}
return grant
}
// No match in range. The "no .zddc anywhere → public" default is
// applied by the EffectiveVerbs wrapper, not here, because callers
// using sub-ranges (e.g. WORM split) want a sub-range with no match
// to contribute nothing rather than implicitly granting everything.
return 0
}
// MatchesPattern checks if email matches a glob pattern.

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"
)
// ACLRules holds email allow/deny lists.
// ACLRules holds the access-control rules at one cascade level.
//
// Three input forms, all merged at parse time into a single map keyed
// by principal (Permissions):
//
// - acl.permissions: { principal → verb-set } — the canonical form.
// Principal is an email pattern (contains "@") or a role name
// (no "@"); roles are looked up via ZddcFile.Roles in this file
// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a};
// empty string is an explicit deny.
//
// - acl.allow: [pattern, ...] — legacy. Each pattern becomes
// Permissions[pattern] = "rwcd" at parse time.
//
// - acl.deny: [pattern, ...] — legacy. Each pattern becomes
// Permissions[pattern] = "" at parse time (explicit deny).
//
// Allow and Deny are retained on the struct for round-trip fidelity
// (and so existing operator-authored .zddc files render unchanged in
// the admin UI); the cascade evaluator reads only Permissions.
//
// JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct {
Allow []string `yaml:"allow" json:"allow,omitempty"`
Deny []string `yaml:"deny" json:"deny,omitempty"`
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
}
// Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see
// MatchesPattern). A role defined at level L is in scope at L and all
// descendants; a level closer to the leaf may shadow an ancestor's
// role definition by redefining the same name.
type Role struct {
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
@ -54,6 +83,17 @@ type ZddcFile struct {
Title string `yaml:"title" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
// Roles are named principal groups available at this level and below.
// See Role for member syntax.
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
// CreatedBy records the email of the user who triggered the .zddc's
// creation via the file API's mkdir post-hook (Incoming/Working/Staging
// only). It is an audit field; the cascade evaluator does not consult
// it. The auto-generated .zddc grants the creator's email directly via
// ACL.Permissions, the same way operators grant access to anyone else.
CreatedBy string `yaml:"created_by,omitempty" json:"created_by,omitempty"`
}
// ParseFile reads and parses a .zddc YAML file.
@ -71,5 +111,30 @@ func ParseFile(path string) (ZddcFile, error) {
if err := yaml.Unmarshal(data, &zf); err != nil {
return ZddcFile{}, err
}
mergeLegacyACL(&zf.ACL)
return zf, nil
}
// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the
// canonical ACL.Permissions map so cascade evaluators only need to
// consult one place. Existing entries in Permissions take precedence
// (operators who specified both forms get the new form's value);
// allow entries become "rwcd" grants, deny entries become "" denies.
func mergeLegacyACL(rules *ACLRules) {
if len(rules.Allow) == 0 && len(rules.Deny) == 0 {
return
}
if rules.Permissions == nil {
rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny))
}
for _, pat := range rules.Allow {
if _, present := rules.Permissions[pat]; !present {
rules.Permissions[pat] = "rwcd"
}
}
for _, pat := range rules.Deny {
if _, present := rules.Permissions[pat]; !present {
rules.Permissions[pat] = ""
}
}
}

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