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:
parent
13ae1498e4
commit
3115e388fc
30 changed files with 3098 additions and 154 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -74,6 +74,16 @@ shared/
|
||||||
base.css CSS tokens and primitives included first by every tool's CSS build
|
base.css CSS tokens and primitives included first by every tool's CSS build
|
||||||
zddc.js canonical filename/folder/revision parsers, formatters, status validation
|
zddc.js canonical filename/folder/revision parsers, formatters, status validation
|
||||||
zddc-filter.js shared ZDDC project/status filter UI module
|
zddc-filter.js shared ZDDC project/status filter UI module
|
||||||
|
zddc-source.js HTTP source abstraction — FS Access API polyfill (HttpDirectoryHandle,
|
||||||
|
HttpFileHandle) backed by zddc-server's listing JSON + file API
|
||||||
|
(PUT/DELETE/POST). Tools that auto-load the current dir in HTTP mode
|
||||||
|
call window.zddc.source.detectServerRoot() at init. The probe
|
||||||
|
returns { handle, status }: status 200 → use handle; 403 → user
|
||||||
|
lacks `r` on this directory (show "no permission to list"
|
||||||
|
message); 0 → not http(s) or non-zddc-server. Tools must
|
||||||
|
handle the 403 case so a permission-locked path doesn't
|
||||||
|
silently render as an empty welcome screen.
|
||||||
|
hash.js SHA-256 helpers used by the file API + classifier hashes
|
||||||
theme.js light/dark theme switcher
|
theme.js light/dark theme switcher
|
||||||
help.js shared help dialog module
|
help.js shared help dialog module
|
||||||
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)
|
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)
|
||||||
|
|
|
||||||
|
|
@ -461,10 +461,12 @@ none of them is load-bearing alone.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
|
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
|
||||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
||||||
|
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
|
||||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||||
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
|
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
|
||||||
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` |
|
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records |
|
||||||
|
| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically |
|
||||||
|
|
||||||
### Commercial vs federal trust model
|
### Commercial vs federal trust model
|
||||||
|
|
||||||
|
|
@ -478,8 +480,8 @@ whether to deploy the system should know which column they're in.
|
||||||
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
|
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
|
||||||
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
||||||
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
||||||
| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) |
|
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
|
||||||
| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) |
|
| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) |
|
||||||
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
||||||
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
||||||
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
||||||
|
|
@ -488,6 +490,53 @@ whether to deploy the system should know which column they're in.
|
||||||
The full bullet list with NIST control references is in
|
The full bullet list with NIST control references is in
|
||||||
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
|
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
|
||||||
|
|
||||||
|
### Permission model: roles + verbs
|
||||||
|
|
||||||
|
Five permission verbs gate every read and write:
|
||||||
|
|
||||||
|
| Verb | Allows |
|
||||||
|
|---|---|
|
||||||
|
| `r` | read file bytes; list directory |
|
||||||
|
| `w` | overwrite an existing file; rename existing file |
|
||||||
|
| `c` | create a new file or directory |
|
||||||
|
| `d` | delete a file |
|
||||||
|
| `a` | modify the ACL of this subtree (write `.zddc`) |
|
||||||
|
|
||||||
|
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny` → `""`), so existing deployments behave identically.
|
||||||
|
|
||||||
|
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`:
|
||||||
|
|
||||||
|
- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny.
|
||||||
|
- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant.
|
||||||
|
|
||||||
|
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
|
||||||
|
|
||||||
|
#### Special folders
|
||||||
|
|
||||||
|
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
|
||||||
|
|
||||||
|
- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
|
||||||
|
- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents.
|
||||||
|
|
||||||
|
The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.
|
||||||
|
|
||||||
|
### File API (authenticated CRUD)
|
||||||
|
|
||||||
|
zddc-server exposes write methods on the same URL space as GET. Each method maps to a specific verb and is gated against the cascade-derived verb set:
|
||||||
|
|
||||||
|
| Method | URL | Headers | Action verb | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `PUT` | `/<new-path>` | `If-Match: "<etag>"` (optional) | `c` | 201 created |
|
||||||
|
| `PUT` | `/<existing-path>` | `If-Match: "<etag>"` (optional) | `w` | 200 overwritten |
|
||||||
|
| `PUT` | `/<dir>/.zddc` | — | `a` | 200/201 |
|
||||||
|
| `DELETE` | `/<path>` | `If-Match: "<etag>"` (optional) | `d` | 204 |
|
||||||
|
| `POST` | `/<path>` | `X-ZDDC-Op: move` + `X-ZDDC-Destination: /new/path` | `w` (src) + `c` (dst) | 200 |
|
||||||
|
| `POST` | `/<path>/` | `X-ZDDC-Op: mkdir` | `c` | 201 created / 200 idempotent |
|
||||||
|
|
||||||
|
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
|
||||||
|
|
||||||
|
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, and transmittal auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
|
||||||
|
|
||||||
### Why the tool-rooted view matters for third-party containment
|
### Why the tool-rooted view matters for third-party containment
|
||||||
|
|
||||||
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool
|
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ concat_files \
|
||||||
"../shared/vendor/docx-preview.min.js" \
|
"../shared/vendor/docx-preview.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -28,33 +28,85 @@
|
||||||
* Initialize the application
|
* Initialize the application
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
|
// Cache DOM elements + wire events first so the welcome screen
|
||||||
|
// (and the HTTP-mode auto-load below) can use them.
|
||||||
|
cacheDOMElements();
|
||||||
|
setupEventListeners();
|
||||||
|
|
||||||
|
// Browser-compatibility branch:
|
||||||
// Check browser compatibility
|
// 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()) {
|
if (!checkBrowserCompatibility()) {
|
||||||
showBrowserWarning();
|
showBrowserWarning();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache DOM elements
|
|
||||||
cacheDOMElements();
|
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
setupEventListeners();
|
|
||||||
|
|
||||||
// Show welcome screen
|
|
||||||
showWelcomeScreen();
|
showWelcomeScreen();
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkBrowserCompatibility()) {
|
||||||
|
showBrowserWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showWelcomeScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if browser supports File System Access API
|
* Check if browser supports File System Access API. Used in local
|
||||||
|
* (file://) mode only — HTTP mode runs through the HTTP polyfill,
|
||||||
|
* which has no browser dependency beyond fetch.
|
||||||
*/
|
*/
|
||||||
function checkBrowserCompatibility() {
|
function checkBrowserCompatibility() {
|
||||||
return 'showDirectoryPicker' in window;
|
return 'showDirectoryPicker' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a clear "no permission to list" message for HTTP-mode users
|
||||||
|
* who land on a path their ACL doesn't allow them to list. Distinct
|
||||||
|
* from the welcome screen so the user understands why the file tree
|
||||||
|
* is empty rather than wondering if they need to pick a folder.
|
||||||
|
*/
|
||||||
|
function showHttpForbiddenMessage() {
|
||||||
|
var screen = document.getElementById('welcomeScreen');
|
||||||
|
if (!screen) return;
|
||||||
|
screen.classList.remove('hidden');
|
||||||
|
var msg = document.createElement('div');
|
||||||
|
msg.className = 'classifier-forbidden-message';
|
||||||
|
msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;';
|
||||||
|
msg.innerHTML =
|
||||||
|
'<h2 style="margin-bottom: 0.75rem;">No permission to list this directory</h2>' +
|
||||||
|
'<p>Your account does not have read access to this folder. ' +
|
||||||
|
'You may still be able to upload files if your role allows it; ' +
|
||||||
|
'contact the document controller if you believe this is wrong.</p>';
|
||||||
|
screen.appendChild(msg);
|
||||||
|
var addBtn = document.getElementById('addDirectoryBtn');
|
||||||
|
if (addBtn) addBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show browser compatibility warning
|
* Show browser compatibility warning
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -647,11 +647,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename by copying to new name and deleting old (more reliable than move)
|
// Rename. HTTP-backed handles (zddc-server) get the atomic
|
||||||
|
// POST /op=move path — single round-trip, server-side
|
||||||
|
// os.Rename, no risk of half-renamed state. Local FS Access
|
||||||
|
// API handles use copy+remove because the API has no native
|
||||||
|
// rename verb.
|
||||||
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
|
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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
|
// Get fresh handle for old file
|
||||||
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
|
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
|
||||||
|
|
||||||
|
|
@ -669,7 +680,7 @@
|
||||||
|
|
||||||
// Update file handle
|
// Update file handle
|
||||||
file.handle = newHandle;
|
file.handle = newHandle;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to rename file:`, err);
|
console.error(`Failed to rename file:`, err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ concat_files \
|
||||||
# JavaScript files to concatenate in order
|
# JavaScript files to concatenate in order
|
||||||
concat_files \
|
concat_files \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -574,43 +574,48 @@ async function refreshDirectory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a synthetic, read-only "file handle" backed by a URL.
|
* Surface a clear "no permission to list this directory" message in
|
||||||
* Implements `getFile()` so the rest of the app (which only needs to read)
|
* the file tree pane when the server returns 403 on the initial
|
||||||
* works without changes. Lacks `createWritable()` — saveFile detects this
|
* listing. Distinct from "host doesn't serve JSON" so the user
|
||||||
* and routes to a Save-As download.
|
* understands why the tree is empty.
|
||||||
*/
|
*/
|
||||||
function createServerFileHandle(name, url) {
|
function showServerForbiddenMessage() {
|
||||||
let cached = null;
|
const treeEl = document.getElementById('file-tree');
|
||||||
return {
|
if (!treeEl) return;
|
||||||
kind: 'file',
|
treeEl.innerHTML =
|
||||||
name,
|
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
|
||||||
_serverUrl: url,
|
'<strong>No permission to list this directory.</strong>' +
|
||||||
_readOnly: true,
|
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
|
||||||
async getFile() {
|
'Contact the document controller if you believe this is wrong.</p>' +
|
||||||
if (cached) return cached;
|
'</div>';
|
||||||
const resp = await fetch(url, { cache: 'no-cache' });
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
|
|
||||||
const lastMod = resp.headers.get('Last-Modified');
|
|
||||||
const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
|
|
||||||
const blob = await resp.blob();
|
|
||||||
cached = new File([blob], name, { type: blob.type, lastModified });
|
|
||||||
return cached;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a synthetic directory handle (read-only) backed by a server URL.
|
* Build a CRUD-capable file handle backed by a URL — uses the shared
|
||||||
* Returned for nested entries so existing code paths that probe for `.handle`
|
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
|
||||||
* still work; not currently used for traversal.
|
* a GET, and createWritable() PUTs bytes back (file API on zddc-server).
|
||||||
|
*
|
||||||
|
* Adds `_serverUrl` for legacy code paths that still expect that field.
|
||||||
|
* Marks `_readOnly: false` so editor.js leaves save buttons enabled.
|
||||||
|
*/
|
||||||
|
function createServerFileHandle(name, url) {
|
||||||
|
const handle = new window.zddc.source.HttpFileHandle(url, name);
|
||||||
|
handle._serverUrl = url;
|
||||||
|
handle._readOnly = false;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CRUD-capable directory handle backed by a server URL — uses
|
||||||
|
* the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
|
||||||
|
* getDirectoryHandle({create}), and removeEntry() against the server
|
||||||
|
* file API. _serverUrl/_readOnly are kept for legacy probes.
|
||||||
*/
|
*/
|
||||||
function createServerDirectoryHandle(name, url) {
|
function createServerDirectoryHandle(name, url) {
|
||||||
return {
|
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
|
||||||
kind: 'directory',
|
handle._serverUrl = url;
|
||||||
name,
|
handle._readOnly = false;
|
||||||
_serverUrl: url,
|
return handle;
|
||||||
_readOnly: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -686,8 +691,16 @@ async function loadServerDirectory() {
|
||||||
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
||||||
// and we must leave "Add Local Directory" visible so the user can still
|
// and we must leave "Add Local Directory" visible so the user can still
|
||||||
// load local files.
|
// load local files.
|
||||||
|
//
|
||||||
|
// 403 means the host is a zddc-server but the user lacks `r` on this
|
||||||
|
// directory (a "no list" permission posture). Show a clear message so
|
||||||
|
// the user understands why the tree is empty.
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||||
|
if (resp.status === 403) {
|
||||||
|
showServerForbiddenMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const items = await resp.json();
|
const items = await resp.json();
|
||||||
if (!Array.isArray(items)) return;
|
if (!Array.isArray(items)) return;
|
||||||
|
|
@ -710,13 +723,13 @@ async function loadServerDirectory() {
|
||||||
entries: {},
|
entries: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Surface refresh, hide write-only controls. "Add Local Directory"
|
// Surface refresh. The server now exposes a CRUD file API, so write
|
||||||
// stays visible (de-emphasized via btn--subtle) so the user can
|
// controls (new file, save, delete) stay enabled — the polyfill
|
||||||
// switch to a local folder at any time.
|
// routes their writes through PUT/DELETE/POST. "Add Local Directory"
|
||||||
|
// is de-emphasized so the user can still load a local folder if they
|
||||||
|
// want, but server-mode is now the default working mode.
|
||||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||||
const newFileRootBtn = document.getElementById('new-file-root');
|
|
||||||
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
|
||||||
const addDirBtn = document.getElementById('addDirectoryBtn');
|
const addDirBtn = document.getElementById('addDirectoryBtn');
|
||||||
if (addDirBtn) {
|
if (addDirBtn) {
|
||||||
addDirBtn.classList.remove('btn-primary');
|
addDirBtn.classList.remove('btn-primary');
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ function createActionButtons(filePath, type) {
|
||||||
const actionsDiv = document.createElement('div');
|
const actionsDiv = document.createElement('div');
|
||||||
actionsDiv.className = 'tree-actions';
|
actionsDiv.className = 'tree-actions';
|
||||||
|
|
||||||
// Server mode is read-only: no rename, delete, or new-file actions.
|
// Server mode now supports full CRUD via the file API — drop the
|
||||||
if (serverSourceMode) return actionsDiv;
|
// legacy short-circuit that hid the rename/delete/new-file actions.
|
||||||
|
|
||||||
if (type === 'directory') {
|
if (type === 'directory') {
|
||||||
// Directory: + (new file) + ✕ (delete)
|
// Directory: + (new file) + ✕ (delete)
|
||||||
|
|
|
||||||
368
shared/zddc-source.js
Normal file
368
shared/zddc-source.js
Normal 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -45,6 +45,7 @@ concat_files \
|
||||||
"../shared/vendor/docx-preview.min.js" \
|
"../shared/vendor/docx-preview.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -1539,4 +1539,37 @@
|
||||||
filesModule.bindActionButtons();
|
filesModule.bindActionButtons();
|
||||||
filesModule.setupTableEditing();
|
filesModule.setupTableEditing();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load when served by zddc-server: the page lives at
|
||||||
|
// /<...>/Staging/<folder>/transmittal.html and that folder IS the
|
||||||
|
// working transmittal. Build an HTTP polyfill handle for it,
|
||||||
|
// assign it as the selected directory, and run the same scan
|
||||||
|
// pipeline the "Add Directory" button does.
|
||||||
|
//
|
||||||
|
// A 403 on the listing probe means the user can't list this folder —
|
||||||
|
// transmittal needs `r` at minimum, so show a clear message rather
|
||||||
|
// than silently leaving the editor empty.
|
||||||
|
app.registerInit(async function () {
|
||||||
|
if (typeof location === 'undefined') { return; }
|
||||||
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') { return; }
|
||||||
|
if (app.data.selectedDirHandle) { return; }
|
||||||
|
try {
|
||||||
|
var probe = await window.zddc.source.detectServerRoot();
|
||||||
|
if (probe.handle) {
|
||||||
|
app.data.selectedDirHandle = probe.handle;
|
||||||
|
updateDirectoryIndicator(probe.handle.name);
|
||||||
|
// Run the same flow as the "Add Directory" button, minus
|
||||||
|
// the click-event plumbing — selectDirectory will skip the
|
||||||
|
// picker because selectedDirHandle is already set.
|
||||||
|
await selectDirectory({ currentTarget: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (probe.status === 403) {
|
||||||
|
console.warn('[transmittal] no permission to list directory; transmittal needs `r` at minimum');
|
||||||
|
updateDirectoryIndicator('— no permission to list this directory —');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[transmittal] HTTP auto-load failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
})(window.transmittalApp);
|
})(window.transmittalApp);
|
||||||
|
|
|
||||||
155
zddc/README.md
155
zddc/README.md
|
|
@ -159,15 +159,59 @@ asymmetry that bites operators on first contact — read "When the cascade helps
|
||||||
when it fights you" below before designing a layout.
|
when it fights you" below before designing a layout.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example .zddc file
|
# Example .zddc — modern schema with verbs and roles
|
||||||
|
roles:
|
||||||
|
_company:
|
||||||
|
members: ["*@mycompany.com"]
|
||||||
|
_doc_controller:
|
||||||
|
members: [dc@mycompany.com]
|
||||||
acl:
|
acl:
|
||||||
allow:
|
permissions:
|
||||||
- "*@mycompany.com" # everyone at mycompany.com
|
_company: r # everyone at mycompany.com gets read
|
||||||
- "contractor@partner.com" # specific external user
|
_doc_controller: rwcda # doc controller gets full control
|
||||||
deny:
|
"contractor@partner.com": rw # specific external — read + overwrite
|
||||||
- "intern@mycompany.com" # override: block this specific user
|
"intern@mycompany.com": "" # explicit deny (empty verb set)
|
||||||
|
|
||||||
|
# Legacy form below still works — equivalent to the new form, with
|
||||||
|
# allow → "rwcd" and deny → "" entries auto-merged into permissions.
|
||||||
|
# acl:
|
||||||
|
# allow: ["*@mycompany.com", "contractor@partner.com"]
|
||||||
|
# deny: ["intern@mycompany.com"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Permission verbs
|
||||||
|
|
||||||
|
Every access decision resolves to a verb set drawn from `r/w/c/d/a`:
|
||||||
|
|
||||||
|
| Verb | Allows |
|
||||||
|
|---|---|
|
||||||
|
| `r` | read file bytes; list directory |
|
||||||
|
| `w` | overwrite an existing file; rename existing file |
|
||||||
|
| `c` | create a new file or directory |
|
||||||
|
| `d` | delete a file |
|
||||||
|
| `a` | modify the ACL of this subtree (write `.zddc`) |
|
||||||
|
|
||||||
|
The verb set is written as concatenated lowercase letters in canonical order — `""` (none / explicit deny), `r`, `cr`, `rwcd`, `rwcda`. Common archetypes:
|
||||||
|
|
||||||
|
- **`r`** — read-only (typical company default).
|
||||||
|
- **`cr`** — append-only / drop-box (the doc controller in `Issued`/`Received`: can file new documents, cannot overwrite or delete).
|
||||||
|
- **`rwcd`** — full content control without the right to change the ACL (vendor inside their working subtree).
|
||||||
|
- **`rwcda`** — full control including the ability to grant access to others (subtree creator; project owner).
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
`roles:` defines named principal groups, available at the level they're declared and all descendants:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
roles:
|
||||||
|
vendor_acme:
|
||||||
|
members: ["*@acme.com"]
|
||||||
|
_doc_controller:
|
||||||
|
members: [dc@mycompany.com, alice@mycompany.com]
|
||||||
|
```
|
||||||
|
|
||||||
|
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work).
|
||||||
|
|
||||||
### Step 1: starter `.zddc`
|
### Step 1: starter `.zddc`
|
||||||
|
|
||||||
Every install should write a root `.zddc` before exposing the bind address. The
|
Every install should write a root `.zddc` before exposing the bind address. The
|
||||||
|
|
@ -187,26 +231,62 @@ that need them. (See worked examples below.)
|
||||||
|
|
||||||
### How a request is evaluated
|
### How a request is evaluated
|
||||||
|
|
||||||
When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along
|
Each request carries an **action verb** (`r` for `GET`, `w` for `PUT` to an
|
||||||
the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest
|
existing file, `c` for `PUT` to a new file or `mkdir`, `d` for `DELETE`, `a`
|
||||||
level first) looking for a match. The first explicit match wins — either an allow
|
for writes to `.zddc`). zddc-server reads every `.zddc` along the chain from
|
||||||
or a deny.
|
`ZDDC_ROOT` down to the request directory, then walks **leaf → root** looking
|
||||||
|
for a level whose `acl.permissions` map matches the user.
|
||||||
|
|
||||||
1. **At the current level**, check deny patterns first. If the email matches any
|
1. **Admin bypass.** If the email is in the root `admins:` list (root admin) or
|
||||||
deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny
|
any subtree-level `admins:` list on the chain (subtree admin), grant
|
||||||
beats allow — see anti-patterns below.)*
|
`rwcda` and skip the cascade entirely.
|
||||||
2. **Same level**, check allow patterns. If the email matches → **allow**, stop
|
2. **At each level**, find every `permissions:` entry whose principal matches
|
||||||
walking.
|
the user (direct email pattern, or role membership via `roles:` lookup).
|
||||||
3. **No match at this level** → walk up to the parent directory's `.zddc` and
|
- If any matching entry has the empty verb set `""` → **403 Forbidden**, stop.
|
||||||
repeat.
|
- Otherwise, take the **union** of matching verb sets at this level. If the
|
||||||
|
union is non-empty, the level "wins" — the requested verb is allowed iff
|
||||||
|
it's present in the union. Stop walking.
|
||||||
|
3. **No match at this level** → walk up to the parent directory's `.zddc`.
|
||||||
4. **No level matched anywhere in the chain:**
|
4. **No level matched anywhere in the chain:**
|
||||||
- If no `.zddc` file existed anywhere in the chain (`HasAnyFile=false`) → **allow** (the empty-tree default; see warning above).
|
- No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
|
||||||
- If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
|
- At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
|
||||||
|
|
||||||
The two functions implementing this are `AllowedAtLevel` (within-level: deny first,
|
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
|
||||||
then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first
|
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
|
||||||
walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is
|
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`).
|
||||||
built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`.
|
|
||||||
|
#### Cascade mode
|
||||||
|
|
||||||
|
The leaf-overrides-ancestor behavior above is the default — it's the historical
|
||||||
|
commercial-tenant model where a subtree owner can grant access without
|
||||||
|
root-admin involvement. Federal deployments needing absolute parent denies
|
||||||
|
(NIST AC-6) start the server with `--cascade-mode=strict` (or
|
||||||
|
`ZDDC_CASCADE_MODE=strict`):
|
||||||
|
|
||||||
|
- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny.
|
||||||
|
- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any
|
||||||
|
matching explicit-deny; if found, denied (subject to root-admin bypass).
|
||||||
|
Second pass is the leaf→root grant walk above. An ancestor explicit-deny
|
||||||
|
cannot be overridden by any leaf grant.
|
||||||
|
|
||||||
|
The mode is logged at startup and surfaced on `/.profile/config`. Subtree
|
||||||
|
`.zddc` files cannot change the mode — it's a deployment-wide policy.
|
||||||
|
|
||||||
|
#### Special folders
|
||||||
|
|
||||||
|
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
|
||||||
|
|
||||||
|
- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /<parent>/<new>/ X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: <email>` and `permissions: { <email>: rwcda }`. The grant uses the same direct email-pattern form an operator would write by hand; the creator can edit the `.zddc` later to add collaborators. `created_by` is an audit field — the cascade evaluator does not consult it.
|
||||||
|
- **`Issued`, `Received`** — *write-once / immutable archive*. When a request path crosses an `Issued` or `Received` segment, the server applies a **WORM split**: cascade grants inherited from ancestors above the WORM folder are masked to `r` only; grants at-or-below the WORM folder retain `r,c`. Anyone with `w`/`d`/`a` from inheritance loses those verbs once they enter the archive. To grant write-once (`cr`) to the doc controller, the operator places an explicit `.zddc` at the `Issued` or `Received` folder:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /<vendor>/Issued/.zddc
|
||||||
|
acl:
|
||||||
|
permissions:
|
||||||
|
_doc_controller: cr
|
||||||
|
```
|
||||||
|
|
||||||
|
The mask preserves the `c` from this same-level grant, so the doc controller can file new documents — but they still cannot overwrite, delete, or change the ACL. **Only admins (root or subtree) can mutate filed documents.** The mask is server-enforced and not configurable in v1; operators who want a non-WORM directory must avoid the names `Issued` and `Received`.
|
||||||
|
|
||||||
### Glob patterns
|
### Glob patterns
|
||||||
|
|
||||||
|
|
@ -586,20 +666,21 @@ have to redo the gap analysis from scratch.
|
||||||
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
|
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
|
||||||
proxy. Required: documented reference deployment with PIV/CAC via
|
proxy. Required: documented reference deployment with PIV/CAC via
|
||||||
oauth2-proxy or equivalent.
|
oauth2-proxy or equivalent.
|
||||||
- **Role-based access control** (NIST AC-3(7)) — current model is per-email
|
- ~~**Role-based access control** (NIST AC-3(7))~~ — *closed.* Roles are
|
||||||
allow/deny + a single root-admin role. Required: roles as first-class
|
first-class entities defined under `roles:` in any `.zddc`, available
|
||||||
entities, `.zddc` syntax for role grants, identity-source-driven role
|
at the level they're declared and all descendants. `acl.permissions`
|
||||||
assignment.
|
grants verb sets (`r`/`w`/`c`/`d`/`a`) per role or per email pattern.
|
||||||
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
|
Identity-source-driven role assignment plumbs through unchanged
|
||||||
Leaf-allow-overrides-parent-deny is the cascade's intentional
|
(the upstream proxy still asserts the email; role membership is
|
||||||
delegation behavior in commercial mode and is preserved in the
|
evaluated server-side against the cascade).
|
||||||
internal Go evaluator. For federal deployments, `--print-rego=federal`
|
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators
|
||||||
emits a parity-tested Rego policy where parent denies are absolute;
|
set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to
|
||||||
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
|
switch the in-process Go evaluator into the federal posture: any
|
||||||
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
|
ancestor explicit-deny is absolute and cannot be overridden by a
|
||||||
that switches the in-process Go evaluator's semantics without
|
leaf grant. The mode is logged at startup and surfaced on
|
||||||
requiring an OPA sidecar — currently federal-mode is reachable only
|
`/.profile/config`. The legacy commercial behavior is preserved as
|
||||||
via the external-OPA path.
|
the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains
|
||||||
|
available for org-specific Rego on top of this.
|
||||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||||
documented integration with at least one IdP supporting federal identity
|
documented integration with at least one IdP supporting federal identity
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ func main() {
|
||||||
URL: cfg.OPAURL,
|
URL: cfg.OPAURL,
|
||||||
FailOpen: cfg.OPAFailOpen,
|
FailOpen: cfg.OPAFailOpen,
|
||||||
CacheTTL: cfg.OPACacheTTL,
|
CacheTTL: cfg.OPACacheTTL,
|
||||||
|
CascadeMode: cfg.CascadeMode,
|
||||||
}
|
}
|
||||||
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
|
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
|
||||||
// the policy package's sentinel for "skip the wrapper").
|
// the policy package's sentinel for "skip the wrapper").
|
||||||
|
|
@ -152,7 +153,8 @@ func main() {
|
||||||
slog.Info("policy decider ready",
|
slog.Info("policy decider ready",
|
||||||
"mode", policyModeLabel(cfg.OPAURL),
|
"mode", policyModeLabel(cfg.OPAURL),
|
||||||
"url", cfg.OPAURL,
|
"url", cfg.OPAURL,
|
||||||
"cache_ttl", cfg.OPACacheTTL)
|
"cache_ttl", cfg.OPACacheTTL,
|
||||||
|
"cascade_mode", cfg.CascadeMode)
|
||||||
|
|
||||||
// Innermost handler: dispatch.
|
// Innermost handler: dispatch.
|
||||||
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -487,6 +489,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File API — authenticated CRUD over the served tree. Catches PUT,
|
||||||
|
// DELETE, and POST on any non-reserved path. Read methods (GET/HEAD)
|
||||||
|
// fall through to the static / apps / directory pipeline below.
|
||||||
|
// Forms and .profile/.archive POSTs are already routed above this
|
||||||
|
// point so they take precedence.
|
||||||
|
if handler.IsWriteMethod(r.Method) {
|
||||||
|
handler.ServeFileAPI(cfg, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Apps resolution for the root landing path: GET / or /index.html with
|
// Apps resolution for the root landing path: GET / or /index.html with
|
||||||
// no real index.html on disk → serve via apps.Serve("landing"). The
|
// no real index.html on disk → serve via apps.Serve("landing"). The
|
||||||
// other four apps are caught by the "stat fails → app HTML?" branch
|
// other four apps are caught by the "stat fails → app HTML?" branch
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,76 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
// import even when we trim test cases later.
|
// import even when we trim test cases later.
|
||||||
var _ = apps.DefaultUpstream
|
var _ = apps.DefaultUpstream
|
||||||
|
|
||||||
|
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
|
||||||
|
// to the file API rather than to the read pipeline.
|
||||||
|
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||||
|
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
|
||||||
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||||
|
|
||||||
|
idx, err := archive.BuildIndex(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
|
}
|
||||||
|
cfg := config.Config{
|
||||||
|
Root: root,
|
||||||
|
IndexPath: ".archive",
|
||||||
|
EmailHeader: "X-Auth-Request-Email",
|
||||||
|
MaxWriteBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
ring := handler.NewLogRing(10)
|
||||||
|
|
||||||
|
withEmail := func(req *http.Request, email string) *http.Request {
|
||||||
|
// dispatch reads email from context (ACLMiddleware would normally
|
||||||
|
// set it), so set it directly here.
|
||||||
|
return req.WithContext(handler.WithEmail(req.Context(), email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT a new file via dispatch.
|
||||||
|
body := []byte("note body")
|
||||||
|
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET it back.
|
||||||
|
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
||||||
|
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOVE it.
|
||||||
|
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com")
|
||||||
|
req.Header.Set("X-ZDDC-Op", "move")
|
||||||
|
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE it.
|
||||||
|
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved segment guard still applies to writes.
|
||||||
|
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustMkdir(t *testing.T, path string) {
|
func mustMkdir(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,19 @@ package apps
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Folder name conventions that gate which tools are virtually available
|
// Folder name conventions that gate which tools are virtually available
|
||||||
// at a given path. The names are case-sensitive; ZDDC convention uses
|
// at a given path. The names are case-sensitive; ZDDC convention uses
|
||||||
// the capitalized forms.
|
// the capitalized forms. The full canonical list lives in
|
||||||
|
// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls
|
||||||
|
// the relevant subsets from there to avoid duplication.
|
||||||
var (
|
var (
|
||||||
folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"}
|
// Subset of zddc.AutoOwnFolderNames where classifier is virtually
|
||||||
|
// available (the same three folders that grant mkdir auto-ownership).
|
||||||
|
folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames
|
||||||
folderNamesWorking = []string{"Working"}
|
folderNamesWorking = []string{"Working"}
|
||||||
folderNamesStaging = []string{"Staging"}
|
folderNamesStaging = []string{"Staging"}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ type Config struct {
|
||||||
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
||||||
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
|
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
|
||||||
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
|
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
|
||||||
|
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
|
||||||
|
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||||
|
|
@ -91,6 +93,10 @@ func Load(args []string) (Config, error) {
|
||||||
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
|
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
|
||||||
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
|
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
|
||||||
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
|
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
|
||||||
|
maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024),
|
||||||
|
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
|
||||||
|
cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"),
|
||||||
|
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
|
||||||
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
||||||
"Tee structured access logs to this file (JSON, size-rotated). "+
|
"Tee structured access logs to this file (JSON, size-rotated). "+
|
||||||
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
||||||
|
|
@ -148,6 +154,8 @@ func Load(args []string) (Config, error) {
|
||||||
OPAFailOpen: *opaFailOpenFlag,
|
OPAFailOpen: *opaFailOpenFlag,
|
||||||
OPACacheTTL: *opaCacheTTLFlag,
|
OPACacheTTL: *opaCacheTTLFlag,
|
||||||
AppsPubKey: *appsPubKeyFlag,
|
AppsPubKey: *appsPubKeyFlag,
|
||||||
|
MaxWriteBytes: *maxWriteBytesFlag,
|
||||||
|
CascadeMode: *cascadeModeFlag,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Root to the current working directory.
|
// Default Root to the current working directory.
|
||||||
|
|
@ -216,6 +224,15 @@ func Load(args []string) (Config, error) {
|
||||||
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
|
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch cfg.CascadeMode {
|
||||||
|
case "", "delegated":
|
||||||
|
cfg.CascadeMode = "delegated"
|
||||||
|
case "strict":
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode)
|
||||||
|
}
|
||||||
|
|
||||||
// Plain HTTP mode trusts the email header from any client. Only safe
|
// Plain HTTP mode trusts the email header from any client. Only safe
|
||||||
// behind an authenticating reverse proxy. Refuse to start when binding
|
// behind an authenticating reverse proxy. Refuse to start when binding
|
||||||
// plain HTTP to a non-loopback interface unless the operator has
|
// plain HTTP to a non-loopback interface unless the operator has
|
||||||
|
|
@ -340,3 +357,16 @@ func parseDurationOrDefault(s string, def time.Duration) time.Duration {
|
||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseInt64OrDefault parses a base-10 int64. Returns def on empty input
|
||||||
|
// or parse error.
|
||||||
|
func parseInt64OrDefault(s string, def int64) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if _, err := fmt.Sscan(s, &n); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@ func CORSMiddleware(cfg config.Config, next http.Handler) http.Handler {
|
||||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||||
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
||||||
}
|
}
|
||||||
h.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
h.Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, POST, OPTIONS")
|
||||||
|
// Expose ETag and the file API's source / destination headers
|
||||||
|
// so cross-origin clients can read them after a write.
|
||||||
|
h.Set("Access-Control-Expose-Headers", "ETag, X-ZDDC-Source, X-ZDDC-Destination")
|
||||||
h.Set("Access-Control-Max-Age", "600")
|
h.Set("Access-Control-Max-Age", "600")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
575
zddc/internal/handler/fileapi.go
Normal file
575
zddc/internal/handler/fileapi.go
Normal 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...)
|
||||||
|
}
|
||||||
666
zddc/internal/handler/fileapi_test.go
Normal file
666
zddc/internal/handler/fileapi_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,13 @@ func EmailFromContext(r *http.Request) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmail returns a context carrying email under EmailKey. Test seam
|
||||||
|
// for handlers that look up the authenticated user via EmailFromContext;
|
||||||
|
// production traffic gets the same value injected by ACLMiddleware.
|
||||||
|
func WithEmail(ctx context.Context, email string) context.Context {
|
||||||
|
return context.WithValue(ctx, EmailKey, email)
|
||||||
|
}
|
||||||
|
|
||||||
// DeciderFromContext extracts the policy decider from the request
|
// DeciderFromContext extracts the policy decider from the request
|
||||||
// context. Returns the internal decider as a fallback if none was
|
// context. Returns the internal decider as a fallback if none was
|
||||||
// installed — this matches the "no OPA configured" semantics and
|
// installed — this matches the "no OPA configured" semantics and
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
||||||
IndexPath string `json:"index_path"`
|
IndexPath string `json:"index_path"`
|
||||||
EmailHeader string `json:"email_header"`
|
EmailHeader string `json:"email_header"`
|
||||||
CORSOrigins []string `json:"cors_origins"`
|
CORSOrigins []string `json:"cors_origins"`
|
||||||
|
CascadeMode string `json:"cascade_mode"`
|
||||||
}
|
}
|
||||||
writeJSON(w, response{
|
writeJSON(w, response{
|
||||||
Root: cfg.Root,
|
Root: cfg.Root,
|
||||||
|
|
@ -204,6 +205,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
||||||
IndexPath: cfg.IndexPath,
|
IndexPath: cfg.IndexPath,
|
||||||
EmailHeader: cfg.EmailHeader,
|
EmailHeader: cfg.EmailHeader,
|
||||||
CORSOrigins: cfg.CORSOrigins,
|
CORSOrigins: cfg.CORSOrigins,
|
||||||
|
CascadeMode: cfg.CascadeMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,18 +58,55 @@ import (
|
||||||
// External Rego policies can:
|
// External Rego policies can:
|
||||||
// - read input.user.email (string)
|
// - read input.user.email (string)
|
||||||
// - read input.path (string)
|
// - read input.path (string)
|
||||||
|
// - read input.action ("read" | "write"); empty/absent ≡ "read"
|
||||||
// - walk input.policy_chain.levels[].acl.{allow,deny} for
|
// - walk input.policy_chain.levels[].acl.{allow,deny} for
|
||||||
// custom cascade semantics, or read the pre-resolved
|
// custom cascade semantics, or read the pre-resolved
|
||||||
// input.policy_chain.has_any_file when implementing the
|
// input.policy_chain.has_any_file when implementing the
|
||||||
// same default-deny rule we use internally.
|
// same default-deny rule we use internally.
|
||||||
|
//
|
||||||
|
// Action distinguishes read (GET/HEAD on listings, files, app HTML)
|
||||||
|
// from write (PUT, DELETE, POST/move on the file API). The internal
|
||||||
|
// decider treats both identically — any allow grants full CRUD,
|
||||||
|
// matching the model in place before the file API existed (anyone
|
||||||
|
// with read access also had OS-level write via the mounted share).
|
||||||
|
// External Rego policies can split the two by inspecting input.action.
|
||||||
type AllowInput struct {
|
type AllowInput struct {
|
||||||
User struct {
|
User struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
|
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action constants used in AllowInput.Action. Empty string is also
|
||||||
|
// accepted for back-compat with callers that don't specify a verb.
|
||||||
|
const (
|
||||||
|
ActionRead = "read" // listing + reading file bytes
|
||||||
|
ActionWrite = "write" // overwriting an existing file (legacy alias for the historical write-vs-read split)
|
||||||
|
ActionCreate = "create" // creating a new file or directory
|
||||||
|
ActionDelete = "delete" // deleting a file
|
||||||
|
ActionAdmin = "admin" // modifying ACL / .zddc / role definitions
|
||||||
|
)
|
||||||
|
|
||||||
|
// actionVerb maps an Action string to the zddc.VerbSet bit it requires.
|
||||||
|
// Returns the read verb for unrecognized values so the internal
|
||||||
|
// decider stays restrictive on unknown action labels.
|
||||||
|
func actionVerb(action string) zddc.VerbSet {
|
||||||
|
switch action {
|
||||||
|
case ActionWrite:
|
||||||
|
return zddc.VerbW
|
||||||
|
case ActionCreate:
|
||||||
|
return zddc.VerbC
|
||||||
|
case ActionDelete:
|
||||||
|
return zddc.VerbD
|
||||||
|
case ActionAdmin:
|
||||||
|
return zddc.VerbA
|
||||||
|
default:
|
||||||
|
return zddc.VerbR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
|
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
|
||||||
// We don't tag zddc.PolicyChain itself because it's tightly coupled
|
// We don't tag zddc.PolicyChain itself because it's tightly coupled
|
||||||
// to the parser; the duplication is one struct.
|
// to the parser; the duplication is one struct.
|
||||||
|
|
@ -92,6 +129,13 @@ type Config struct {
|
||||||
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
|
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
|
||||||
FailOpen bool // external mode only: on transport error, allow instead of deny
|
FailOpen bool // external mode only: on transport error, allow instead of deny
|
||||||
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
|
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
|
||||||
|
|
||||||
|
// CascadeMode controls how the InternalDecider walks the ACL chain:
|
||||||
|
// "delegated" (default — leaf grants override ancestor denies) or
|
||||||
|
// "strict" (ancestor explicit-deny is absolute; NIST AC-6).
|
||||||
|
// External deciders ignore this — Rego policies access the chain
|
||||||
|
// directly and implement either semantic themselves.
|
||||||
|
CascadeMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Decider per cfg.URL semantics.
|
// New constructs a Decider per cfg.URL semantics.
|
||||||
|
|
@ -106,8 +150,9 @@ type Config struct {
|
||||||
//
|
//
|
||||||
// Returns an error if URL is unrecognized.
|
// Returns an error if URL is unrecognized.
|
||||||
func New(cfg Config) (Decider, error) {
|
func New(cfg Config) (Decider, error) {
|
||||||
|
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
|
||||||
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
|
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
|
||||||
return &InternalDecider{}, nil
|
return &InternalDecider{Mode: mode}, nil
|
||||||
}
|
}
|
||||||
var inner Decider
|
var inner Decider
|
||||||
var err error
|
var err error
|
||||||
|
|
@ -140,10 +185,16 @@ func New(cfg Config) (Decider, error) {
|
||||||
return &cachingDecider{inner: inner, ttl: ttl}, nil
|
return &cachingDecider{inner: inner, ttl: ttl}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InternalDecider routes Allow through zddc.AllowedWithChain. No
|
// InternalDecider routes Allow through zddc.AllowedAction with the
|
||||||
// network, no Rego, no new dependencies — same Go evaluator the
|
// configured cascade mode and applies the Issued/Received WORM mask
|
||||||
// existing test suite covers.
|
// post-decision. No network, no Rego, no new dependencies.
|
||||||
type InternalDecider struct{}
|
//
|
||||||
|
// The decider does NOT consult the admins:/IsAdmin escape hatch —
|
||||||
|
// callers in the handler package wire IsAdmin / IsSubtreeAdmin around
|
||||||
|
// the decision. Admins bypass the WORM mask there as well.
|
||||||
|
type InternalDecider struct {
|
||||||
|
Mode zddc.CascadeMode
|
||||||
|
}
|
||||||
|
|
||||||
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
|
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
|
||||||
chain := zddc.PolicyChain{}
|
chain := zddc.PolicyChain{}
|
||||||
|
|
@ -151,7 +202,28 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
|
||||||
chain.Levels = input.PolicyChain.Levels
|
chain.Levels = input.PolicyChain.Levels
|
||||||
chain.HasAnyFile = input.PolicyChain.HasAnyFile
|
chain.HasAnyFile = input.PolicyChain.HasAnyFile
|
||||||
}
|
}
|
||||||
return zddc.AllowedWithChain(chain, input.User.Email), nil
|
verb := actionVerb(input.Action)
|
||||||
|
email := input.User.Email
|
||||||
|
|
||||||
|
// WORM split: in Issued/Received, ancestor grants are read-only;
|
||||||
|
// only an explicit .zddc placed at-or-below the WORM folder can
|
||||||
|
// restore `c` (write-once) for principals it names. Admins are
|
||||||
|
// excluded from this code path by callers (handler package does
|
||||||
|
// the IsAdmin / IsSubtreeAdmin bypass before invoking Allow).
|
||||||
|
//
|
||||||
|
// EffectiveVerbsRange (rather than slicing chain.Levels) keeps the
|
||||||
|
// FULL chain visible to role-membership lookups so an ancestor's
|
||||||
|
// role definition still applies inside the sub-range walk.
|
||||||
|
if zddc.IsWormPath(input.Path) {
|
||||||
|
wormIdx := zddc.WormFolderLevelIndex(input.Path, len(chain.Levels))
|
||||||
|
if wormIdx >= 0 {
|
||||||
|
grantAbove := zddc.EffectiveVerbsRange(chain, 0, wormIdx, email, d.Mode) & zddc.VerbR
|
||||||
|
grantBelow := zddc.EffectiveVerbsRange(chain, wormIdx, len(chain.Levels), email, d.Mode) & zddc.VerbsRC
|
||||||
|
return (grantAbove | grantBelow).Has(verb), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zddc.AllowedAction(chain, email, verb, d.Mode), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
|
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
|
||||||
|
|
@ -240,9 +312,29 @@ func (d *HTTPDecider) failResult(err error) (bool, error) {
|
||||||
|
|
||||||
// AllowFromChain is a convenience for callers that already have a
|
// AllowFromChain is a convenience for callers that already have a
|
||||||
// PolicyChain in hand. Equivalent to constructing AllowInput manually
|
// PolicyChain in hand. Equivalent to constructing AllowInput manually
|
||||||
// from (chain, email, path) and calling d.Allow.
|
// from (chain, email, path) and calling d.Allow. Implies "read".
|
||||||
|
//
|
||||||
|
// New callers should use AllowActionFromChain with an explicit verb so
|
||||||
|
// the audit/policy stream records intent and the internal decider can
|
||||||
|
// apply the right verb-specific check.
|
||||||
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
|
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
|
||||||
in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)}
|
return AllowActionFromChain(ctx, d, chain, email, path, ActionRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowWriteFromChain is the legacy write-action helper. Newer callers
|
||||||
|
// should pick the specific verb (ActionCreate / ActionWrite /
|
||||||
|
// ActionDelete / ActionAdmin) via AllowActionFromChain instead.
|
||||||
|
func AllowWriteFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
|
||||||
|
return AllowActionFromChain(ctx, d, chain, email, path, ActionWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowActionFromChain is the canonical access-decision helper.
|
||||||
|
// External Rego policies can branch on input.action to differentiate
|
||||||
|
// among the five verbs (read / write / create / delete / admin). The
|
||||||
|
// internal decider maps each action to its zddc.VerbSet bit and walks
|
||||||
|
// the cascade in the configured mode (delegated / strict).
|
||||||
|
func AllowActionFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path, action string) (bool, error) {
|
||||||
|
in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)}
|
||||||
in.User.Email = email
|
in.User.Email = email
|
||||||
return d.Allow(ctx, in)
|
return d.Allow(ctx, in)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,165 @@ package zddc
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// AllowedAtLevel checks whether email is explicitly allowed or denied by a single
|
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
|
||||||
// .zddc level. Returns (decision, matched):
|
// callers that only need the legacy boolean read decision. New code
|
||||||
// - (false, true) — email matched a deny pattern → deny
|
// should call GrantedVerbsAtLevel directly.
|
||||||
// - (true, true) — email matched an allow pattern → allow
|
|
||||||
// - (false, false) — no match in this level → keep walking up
|
|
||||||
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
||||||
// deny checked first
|
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
||||||
for _, pattern := range level.ACL.Deny {
|
v, m := GrantedVerbsAtLevel(chain, 0, email)
|
||||||
if MatchesPattern(pattern, email) {
|
if !m {
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, pattern := range level.ACL.Allow {
|
|
||||||
if MatchesPattern(pattern, email) {
|
|
||||||
return true, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
return v.Has(VerbR), true
|
||||||
|
}
|
||||||
|
|
||||||
// AllowedWithChain evaluates a PolicyChain bottom-up (deepest level first).
|
// GrantedVerbsAtLevel computes the verb set granted to email at
|
||||||
// First explicit match (allow or deny) wins.
|
// chain.Levels[levelIdx]. Returns (set, matched):
|
||||||
// If no level matches and HasAnyFile is false → allow (no rules = public).
|
// - matched=false → no entry in this level matches the user; cascade walks on
|
||||||
// If no level matches and HasAnyFile is true → deny (user not on any list).
|
// - matched=true, set={} → an entry matched with the empty verb set; explicit deny
|
||||||
|
// - matched=true, set!={} → union of verb sets from every matching entry
|
||||||
|
//
|
||||||
|
// Role lookups for principal keys without "@" use RoleMembers, which
|
||||||
|
// walks levelIdx → root for the closest definition.
|
||||||
|
//
|
||||||
|
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
|
||||||
|
// parse time) so this function works correctly on test-constructed
|
||||||
|
// ZddcFile literals as well as parser output.
|
||||||
|
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
|
||||||
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
level := chain.Levels[levelIdx]
|
||||||
|
perms := effectivePermissions(level.ACL)
|
||||||
|
if len(perms) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := false
|
||||||
|
deniedExplicit := false
|
||||||
|
var grant VerbSet
|
||||||
|
for principal, verbStr := range perms {
|
||||||
|
if !MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched = true
|
||||||
|
v, _ := ParseVerbSet(verbStr) // unknown letters silently dropped
|
||||||
|
if verbStr == "" {
|
||||||
|
deniedExplicit = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grant = grant.Union(v)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if deniedExplicit {
|
||||||
|
// Empty-set match wins over any grant entries at the same level —
|
||||||
|
// explicit deny is always more specific than a permissive role
|
||||||
|
// membership at the same scope.
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
return grant, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectivePermissions returns the union of acl.permissions and the
|
||||||
|
// legacy acl.allow / acl.deny fields, with permissions winning on
|
||||||
|
// collision. Returns nil if all three are empty. Does not mutate rules.
|
||||||
|
func effectivePermissions(rules ACLRules) map[string]string {
|
||||||
|
if len(rules.Permissions) == 0 && len(rules.Allow) == 0 && len(rules.Deny) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(rules.Permissions)+len(rules.Allow)+len(rules.Deny))
|
||||||
|
for _, pat := range rules.Allow {
|
||||||
|
out[pat] = "rwcd"
|
||||||
|
}
|
||||||
|
for _, pat := range rules.Deny {
|
||||||
|
out[pat] = ""
|
||||||
|
}
|
||||||
|
for k, v := range rules.Permissions {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedWithChain evaluates a PolicyChain leaf→root (deepest level first)
|
||||||
|
// for the read action. Preserved for legacy callers and existing read paths
|
||||||
|
// that haven't migrated to AllowedAction yet.
|
||||||
func AllowedWithChain(chain PolicyChain, email string) bool {
|
func AllowedWithChain(chain PolicyChain, email string) bool {
|
||||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
return AllowedAction(chain, email, VerbR, ModeDelegated)
|
||||||
decision, matched := AllowedAtLevel(chain.Levels[i], email)
|
}
|
||||||
if matched {
|
|
||||||
return decision
|
// AllowedAction evaluates a PolicyChain for a specific verb and cascade mode.
|
||||||
|
// Thin wrapper around EffectiveVerbs that surfaces the boolean answer.
|
||||||
|
func AllowedAction(chain PolicyChain, email string, verb VerbSet, mode CascadeMode) bool {
|
||||||
|
return EffectiveVerbs(chain, email, mode).Has(verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveVerbs computes the verb set granted to email by the cascade.
|
||||||
|
// Walks the full chain and applies the default-allow rule (no .zddc
|
||||||
|
// anywhere → public access).
|
||||||
|
func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet {
|
||||||
|
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode)
|
||||||
|
if v == 0 && !chain.HasAnyFile {
|
||||||
|
// Public-tree default: empty chain with no .zddc files anywhere
|
||||||
|
// → grant everything. EffectiveVerbsRange returns 0 in this
|
||||||
|
// case because it has no opinion on default semantics outside
|
||||||
|
// a sub-range walk; the full-chain wrapper applies the rule.
|
||||||
|
return VerbAll
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveVerbsRange computes the verb set granted by walking only
|
||||||
|
// chain.Levels[fromIdx:toIdx] for matching permission entries. Role
|
||||||
|
// definitions are still looked up over the FULL chain via
|
||||||
|
// GrantedVerbsAtLevel → MatchesPrincipal → lookupRoleMembers, so an
|
||||||
|
// ancestor's role definition remains visible to a sub-range walk.
|
||||||
|
//
|
||||||
|
// Used by the WORM split: above-the-WORM-folder and at-or-below-the-
|
||||||
|
// WORM-folder are evaluated as separate ranges, then their grants are
|
||||||
|
// masked and unioned.
|
||||||
|
//
|
||||||
|
// Cascade mode controls whether ancestor explicit-denies are absolute
|
||||||
|
// (Strict) or can be overridden by a leaf grant (Delegated). The
|
||||||
|
// strict-mode pass is restricted to the same range — splitting the
|
||||||
|
// chain implies splitting the strict-mode walk too.
|
||||||
|
//
|
||||||
|
// This function does NOT consult the admins:/IsAdmin escape hatch and
|
||||||
|
// does NOT apply the Issued/Received WORM mask.
|
||||||
|
func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mode CascadeMode) VerbSet {
|
||||||
|
if fromIdx < 0 {
|
||||||
|
fromIdx = 0
|
||||||
|
}
|
||||||
|
if toIdx > len(chain.Levels) {
|
||||||
|
toIdx = len(chain.Levels)
|
||||||
|
}
|
||||||
|
if fromIdx >= toIdx {
|
||||||
|
// Empty range — no levels to consult. Caller is responsible
|
||||||
|
// for the default-deny semantics in this case (typically the
|
||||||
|
// caller has another range to combine with).
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if mode == ModeStrict {
|
||||||
|
for i := fromIdx; i < toIdx; i++ {
|
||||||
|
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||||
|
if matched && grant == 0 {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !chain.HasAnyFile
|
}
|
||||||
|
for i := toIdx - 1; i >= fromIdx; i-- {
|
||||||
|
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return grant
|
||||||
|
}
|
||||||
|
// No match in range. The "no .zddc anywhere → public" default is
|
||||||
|
// applied by the EffectiveVerbs wrapper, not here, because callers
|
||||||
|
// using sub-ranges (e.g. WORM split) want a sub-range with no match
|
||||||
|
// to contribute nothing rather than implicitly granting everything.
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesPattern checks if email matches a glob pattern.
|
// MatchesPattern checks if email matches a glob pattern.
|
||||||
|
|
|
||||||
48
zddc/internal/zddc/cascade_mode.go
Normal file
48
zddc/internal/zddc/cascade_mode.go
Normal 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
|
||||||
|
}
|
||||||
108
zddc/internal/zddc/cascade_mode_test.go
Normal file
108
zddc/internal/zddc/cascade_mode_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,14 +6,43 @@ import (
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ACLRules holds email allow/deny lists.
|
// ACLRules holds the access-control rules at one cascade level.
|
||||||
|
//
|
||||||
|
// Three input forms, all merged at parse time into a single map keyed
|
||||||
|
// by principal (Permissions):
|
||||||
|
//
|
||||||
|
// - acl.permissions: { principal → verb-set } — the canonical form.
|
||||||
|
// Principal is an email pattern (contains "@") or a role name
|
||||||
|
// (no "@"); roles are looked up via ZddcFile.Roles in this file
|
||||||
|
// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a};
|
||||||
|
// empty string is an explicit deny.
|
||||||
|
//
|
||||||
|
// - acl.allow: [pattern, ...] — legacy. Each pattern becomes
|
||||||
|
// Permissions[pattern] = "rwcd" at parse time.
|
||||||
|
//
|
||||||
|
// - acl.deny: [pattern, ...] — legacy. Each pattern becomes
|
||||||
|
// Permissions[pattern] = "" at parse time (explicit deny).
|
||||||
|
//
|
||||||
|
// Allow and Deny are retained on the struct for round-trip fidelity
|
||||||
|
// (and so existing operator-authored .zddc files render unchanged in
|
||||||
|
// the admin UI); the cascade evaluator reads only Permissions.
|
||||||
//
|
//
|
||||||
// JSON tags are present so this type round-trips cleanly when included
|
// JSON tags are present so this type round-trips cleanly when included
|
||||||
// in the external-OPA input body (see internal/policy). The canonical
|
// in the external-OPA input body (see internal/policy). The canonical
|
||||||
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
||||||
type ACLRules struct {
|
type ACLRules struct {
|
||||||
Allow []string `yaml:"allow" json:"allow,omitempty"`
|
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
|
||||||
Deny []string `yaml:"deny" json:"deny,omitempty"`
|
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
|
||||||
|
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role is the named principal-grouping primitive. Members are email
|
||||||
|
// patterns (same syntax as the legacy allow/deny entries — see
|
||||||
|
// MatchesPattern). A role defined at level L is in scope at L and all
|
||||||
|
// descendants; a level closer to the leaf may shadow an ancestor's
|
||||||
|
// role definition by redefining the same name.
|
||||||
|
type Role struct {
|
||||||
|
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
||||||
|
|
@ -54,6 +83,17 @@ type ZddcFile struct {
|
||||||
Title string `yaml:"title" json:"title,omitempty"`
|
Title string `yaml:"title" json:"title,omitempty"`
|
||||||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||||
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
||||||
|
|
||||||
|
// Roles are named principal groups available at this level and below.
|
||||||
|
// See Role for member syntax.
|
||||||
|
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||||
|
|
||||||
|
// CreatedBy records the email of the user who triggered the .zddc's
|
||||||
|
// creation via the file API's mkdir post-hook (Incoming/Working/Staging
|
||||||
|
// only). It is an audit field; the cascade evaluator does not consult
|
||||||
|
// it. The auto-generated .zddc grants the creator's email directly via
|
||||||
|
// ACL.Permissions, the same way operators grant access to anyone else.
|
||||||
|
CreatedBy string `yaml:"created_by,omitempty" json:"created_by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFile reads and parses a .zddc YAML file.
|
// ParseFile reads and parses a .zddc YAML file.
|
||||||
|
|
@ -71,5 +111,30 @@ func ParseFile(path string) (ZddcFile, error) {
|
||||||
if err := yaml.Unmarshal(data, &zf); err != nil {
|
if err := yaml.Unmarshal(data, &zf); err != nil {
|
||||||
return ZddcFile{}, err
|
return ZddcFile{}, err
|
||||||
}
|
}
|
||||||
|
mergeLegacyACL(&zf.ACL)
|
||||||
return zf, nil
|
return zf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the
|
||||||
|
// canonical ACL.Permissions map so cascade evaluators only need to
|
||||||
|
// consult one place. Existing entries in Permissions take precedence
|
||||||
|
// (operators who specified both forms get the new form's value);
|
||||||
|
// allow entries become "rwcd" grants, deny entries become "" denies.
|
||||||
|
func mergeLegacyACL(rules *ACLRules) {
|
||||||
|
if len(rules.Allow) == 0 && len(rules.Deny) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rules.Permissions == nil {
|
||||||
|
rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny))
|
||||||
|
}
|
||||||
|
for _, pat := range rules.Allow {
|
||||||
|
if _, present := rules.Permissions[pat]; !present {
|
||||||
|
rules.Permissions[pat] = "rwcd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pat := range rules.Deny {
|
||||||
|
if _, present := rules.Permissions[pat]; !present {
|
||||||
|
rules.Permissions[pat] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
191
zddc/internal/zddc/roles.go
Normal file
191
zddc/internal/zddc/roles.go
Normal 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
|
||||||
|
}
|
||||||
124
zddc/internal/zddc/roles_test.go
Normal file
124
zddc/internal/zddc/roles_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
122
zddc/internal/zddc/special.go
Normal file
122
zddc/internal/zddc/special.go
Normal 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
|
||||||
|
}
|
||||||
76
zddc/internal/zddc/special_test.go
Normal file
76
zddc/internal/zddc/special_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue