diff --git a/AGENTS.md b/AGENTS.md index 05fe7f0..3ffb65b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -391,7 +391,11 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \ | `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) | | `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment | | `ZDDC_LOG_LEVEL` | `info` | Logging verbosity | -| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. | +| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). | +| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. | +| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. | +| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). | +| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. | | `ZDDC_ACCESS_LOG` | `/.zddc.d/logs/access-.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. | ### Release tagging @@ -420,7 +424,7 @@ local path that fails loudly and visibly on the developer's terminal. - No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+) - Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories - Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. -- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`) +- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model." - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". - `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec1c77d..dfad8c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -443,6 +443,73 @@ app.state.subscribe((property, newValue) => { --- +## Server security model + +zddc-server is the deployable surface — it gates access to the file tree that +all the HTML tools work against. The trust story is intentionally narrow, +delegated, and small enough to audit. This section frames it; operator detail +(syntax, anti-patterns, worked layouts, verification recipe, federal-readiness +gap analysis) lives in [`zddc/README.md`](zddc/README.md) § "Access control: +the `.zddc` cascade." + +### Cooperating layers + +Six layers cooperate to bound what a request can reach. Each does one job; +none of them is load-bearing alone. + +| Layer | Job | Implementation | +|---|---|---| +| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate | +| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` | +| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | +| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above | +| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only | +| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `/.zddc.d/logs/access-.log` | + +### Commercial vs federal trust model + +The current implementation is well-shaped for a commercial-tenant model with +delegated auth. Federal-grade qualification (FedRAMP Moderate, NIST 800-53, +FIPS 140-3, DoD STIG) requires several layers to harden. Operators deciding +whether to deploy the system should know which column they're in. + +| Property | Commercial trust model (current) | Federal trust model (gaps to close) | +|---|---|---| +| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP | +| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) | +| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS | +| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) | +| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) | +| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive | +| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery | +| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification | +| Disclosure process | Not formalized | `SECURITY.md`, embargoed-fix workflow, CVE assignment | + +The full bullet list with NIST control references is in +[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis." + +### Why the tool-rooted view matters for third-party containment + +A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool +served at that URL, with that subtree as its world. There is no breadcrumb to +`/Archive/`, no "go to root" button, no listing of sibling vendor folders. +This is **UX containment** — it makes the deployment feel self-contained and +prevents accidental discovery of out-of-scope paths. It is **not** the +security mechanism. Even if a vendor hand-crafts a URL to `/Archive/` or +`/`, the cascade's default-deny rejects them before any byte is served. The +tool-rooted view exists so a vendor reading their email link doesn't have to +choose between "click something I shouldn't" and "click nothing"; the +cascade exists so that choice never matters. + +This pairing — UI affordance contained, ACL enforced — is why the server +auto-serves `archive.html` at *every* directory under `ZDDC_ROOT`. If the +archive tool only worked at root, every locked-down subtree would need +either a hand-crafted entrypoint or a tool that knew how to scope itself. +Auto-serving makes "vendor's world starts at `/Archive//`" the +out-of-the-box behavior with no per-deployment configuration. + +--- + ## CSS Architecture All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS). diff --git a/zddc/README.md b/zddc/README.md index afafc48..f2c0e77 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -57,30 +57,47 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to | `ZDDC_TLS_CERT` | *(empty)* | Path to PEM certificate file. `none` = plain HTTP (no TLS); empty = generate self-signed | | `ZDDC_TLS_KEY` | *(empty)* | Path to PEM private key file. Required when `ZDDC_TLS_CERT` is a file path; ignored otherwise | | `ZDDC_INSECURE_DIRECT` | *(empty)* | Must be `1` when `ZDDC_TLS_CERT=none` and the bind address is non-loopback. Acknowledges that an authenticating reverse proxy is in front of zddc-server; without it, plain-HTTP non-loopback startup is refused | +| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup when `/.zddc` is missing. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default access. Set only for deliberately-public deployments | +| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` = built-in Go evaluator (default). `http(s)://...` or `unix:///...` = external OPA-compatible server (federal deployments using their own audited Rego). See "External policy decider" below. | +| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = on transport error, allow the request (availability over correctness). Default = fail closed (deny). Never set to `1` in federal contexts. | +| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — bursts of identical queries (a single `.archive` listing can hit the same `(email, dir)` tuple many times) collapse to one OPA round-trip. Set `0` to disable. Format is Go's `time.ParseDuration` (`500ms`, `2s`, `1m`). | | `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | | `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index | | `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) | -| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty value disables CORS entirely. Default lets ZDDC tools served from `zddc.varasys.io` (e.g. via the bootstrap pattern) call back into your deployed server. | +| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty (default) disables CORS — appropriate when zddc-server's embedded tools serve same-origin. Set explicitly only if browser-loaded pages from a different origin call back into this server (e.g. `https://tools.acme.com` for self-hosted tools, or `https://zddc.varasys.io` for the CDN-bootstrap pattern) | | `ZDDC_ACCESS_LOG` | `/.zddc.d/logs/access-.log` | Tee'd structured access log. Auto-mkdir on first run. Empty value (set explicitly with `--access-log=`) disables file logging; stderr stream stays. Per-host filenames let multiple replicas write to the same `.zddc.d/` directory without collision; every record carries a `host` field for downstream aggregation. | `ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates. ### CORS -The default `ZDDC_CORS_ORIGIN=https://zddc.varasys.io` exists so the canonical -ZDDC tool builds (hosted at `zddc.varasys.io`) can call back into your -deployed `zddc-server` without extra configuration. If you self-host the -tools on your own domain (e.g. `tools.acme.com`), set: +CORS is **disabled by default** — `ZDDC_CORS_ORIGIN` defaults to empty. +The embedded-tools install path serves tools and data from the same origin +(both come from zddc-server itself), so no cross-origin allowlist is needed +and there is no implicit cross-origin trust to a third-party host. + +Set the value explicitly only when browser-loaded pages from a *different* +origin need to call back into this server. Two scenarios: ```sh +# Self-hosted tools on a separate domain ZDDC_CORS_ORIGIN=https://tools.acme.com + +# CDN-bootstrap pattern (loading tools from the canonical upstream and +# pointing them at your server) — opt in to this trust explicitly +ZDDC_CORS_ORIGIN=https://zddc.varasys.io ``` -Multiple origins are comma-separated. To disable CORS entirely (e.g. when -all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The -middleware echoes the matched origin back per-request and sets -`Access-Control-Allow-Credentials: true` so the upstream-set -`X-Auth-Request-Email` header crosses the boundary. +Multiple origins are comma-separated. The middleware echoes the matched +origin back per-request and sets `Access-Control-Allow-Credentials: true` +so the upstream-set `X-Auth-Request-Email` header crosses the boundary. + +> Why empty by default? Earlier releases defaulted this to +> `https://zddc.varasys.io` for the CDN-bootstrap convenience, but every +> deployment then implicitly trusted that origin to make authenticated +> cross-origin XHRs on behalf of any logged-in user. That's an unusual +> trust assumption to bake into a default. Now you opt in explicitly when +> you actually need it. ## TLS @@ -119,46 +136,76 @@ zddc-server does **not** perform authentication itself. It reads the user's emai from a request header (default: `X-Auth-Request-Email`) that must be set by an upstream reverse proxy (nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user. -If the header is absent, the user is treated as anonymous (empty email). A directory with -no `.zddc` rules is publicly accessible; a directory with an allowlist requires a matching -email. +If the header is absent, the user is treated as anonymous (empty email). A request is +allowed only if (a) **no `.zddc` file exists anywhere in the chain from `ZDDC_ROOT` to +the requested directory** (a fresh tree with zero `.zddc` files defaults to public +access — see warning at the top of the next section), or (b) some level in the chain +explicitly allows the caller's email. See "Access control: the `.zddc` cascade" below +for the full evaluation order. -## `.zddc` Access Control Files +## Access control: the `.zddc` cascade -Place a `.zddc` YAML file in any directory to control access. Rules cascade from parent -directories — child rules are appended to (not replaced by) parent rules. +> ⚠️ **zddc-server refuses to start without a root `.zddc`.** A `ZDDC_ROOT` containing +> no `.zddc` files anywhere would default to allow-all (anonymous callers included), +> so the server fails fast at startup with a clear error. Pass `--insecure` (or +> `ZDDC_INSECURE=1`) to acknowledge a deliberately-public deployment, otherwise +> drop a starter `/.zddc` per "Step 1" below. + +`zddc-server` enforces access via cascading `.zddc` YAML files: drop one in any +directory, and its rules apply to that directory and everything beneath it that doesn't +override. The model is small enough to hold in your head, but the cascade has one +asymmetry that bites operators on first contact — read "When the cascade helps and +when it fights you" below before designing a layout. ```yaml # Example .zddc file acl: allow: - - "*@mycompany.com" # all users at mycompany.com + - "*@mycompany.com" # everyone at mycompany.com - "contractor@partner.com" # specific external user deny: - - "intern@mycompany.com" # override: block this specific user + - "intern@mycompany.com" # override: block this specific user ``` -### ACL evaluation order +### Step 1: starter `.zddc` -Rules are evaluated **bottom-up**: starting at the requested directory and walking -toward the root. The first explicit match (allow or deny) at any level wins. +Every install should write a root `.zddc` before exposing the bind address. The +minimum is an `admins:` line so the admin debug page works (see "Admin Debug Page" +below) — adding `acl:` is optional at this step: -1. Check deny patterns at the current level — if email matches → **403 Forbidden** -2. Check allow patterns at the current level — if email matches → **allow** -3. No match at this level → walk up to parent directory and repeat -4. If no `.zddc` files were found anywhere in the chain → **allow** (public, no rules) -5. If `.zddc` files exist but email matched nothing → **403 Forbidden** (not on any list) +```yaml +# /.zddc — bare minimum +admins: + - you@yourcompany.com +``` -This model supports three user tiers in a single tree: +With this single file in place, `HasAnyFile` becomes `true` for every directory in +the tree and the default switches from "allow-all-anonymous" to "deny-anything-not- +explicitly-allowed." From here you grant access by adding `acl:` rules at the levels +that need them. (See worked examples below.) -| Level | Rule | Effect | -|---|---|---| -| Root | `allow: ["*@company.com"]` | All company users see everything | -| Project dir | `allow: ["team@company.com"]` | Restricts to the project team | -| Vendor subdir | `allow: ["vendor@ext.com"]` | Grants a third-party access to their folder only | +### How a request is evaluated -A vendor navigating to their subdirectory is allowed by the deepest matching rule, -even if a higher-level rule would deny them. +When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along +the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest +level first) looking for a match. The first explicit match wins — either an allow +or a deny. + +1. **At the current level**, check deny patterns first. If the email matches any + deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny + beats allow — see anti-patterns below.)* +2. **Same level**, check allow patterns. If the email matches → **allow**, stop + walking. +3. **No match at this level** → walk up to the parent directory's `.zddc` and + repeat. +4. **No level matched anywhere in the chain:** + - If no `.zddc` file existed anywhere in the chain (`HasAnyFile=false`) → **allow** (the empty-tree default; see warning above). + - If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny). + +The two functions implementing this are `AllowedAtLevel` (within-level: deny first, +then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first +walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is +built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`. ### Glob patterns @@ -171,11 +218,233 @@ even if a higher-level rule would deny them. | `*` | Any non-empty email | | `alice@example.com` | Exact match only | +The `*` does **not** cross the `@`. Implementation at `zddc/internal/zddc/acl.go:52`. + +### When the cascade helps and when it fights you + +The cascade is well-suited to one shape and clumsy at another. Internalize the +asymmetry before designing your layout: + +- **Adding a new email at a leaf is easy** (third parties, occasional contractors). + The new email doesn't match any rule at higher levels, so the cascade just grants + the leaf-level allow and silently default-denies them everywhere else. No extra + `deny:` rules needed. +- **Excluding insiders from a leaf is harder** (commercially sensitive subset of + an otherwise company-wide tree). If a parent has `allow: ["*@company.com"]`, a + leaf-level `allow: [alice@company.com]` *adds* alice on top of everyone — it + does not subtract everyone-else. Subtracting requires either (a) the two-level + gate-and-reallow pattern in the worked example below, or (b) not having the + permissive parent rule in the first place. + +This shape is intentional: the cascade is designed for **delegation** (subtree +owners can grant access without coordinating with central admin), not for +fine-grained subtractive policy. If your model is "everyone has access by default, +specific dirs are restricted," push the wildcards downward off the root rather than +fighting the cascade. + +### Pick your layout + +| Your shape | Recommended pattern | +|---|---| +| Solo / single-user archive | One `.zddc` at root with `admins: [you]`, no `acl:` block — root-only restriction | +| Single small team, full sharing | Root `.zddc` with `acl: {allow: ["*@team.com"]}`. No subdir overrides needed | +| Multi-tenant: each tenant in own subdir | Empty root `.zddc` (just `admins:`), per-tenant `/.zddc` with `acl: {allow: []}`. Tenants don't see each other's listings | +| Mixed: half open within company, half locked-down to a subset, plus third-party folders | The worked example below — careful: do **not** put `*@company.com` at root | + +### Worked example: paired open/closed projects + third-party archive + +This is the deployment shape most operators end up with: technical projects are +shared company-wide; their commercial siblings are restricted to a subset; a +separate `/Archive/` tree holds per-vendor folders where each vendor sees only +their own subdir. The cascade handles all three with no `deny:` rules — but only +if you keep the `*@company.com` wildcard *off the root*. + +```yaml +# /.zddc — admins only, no broad ACL +admins: + - admin@mycompany.com +``` + +```yaml +# /Acme-tech/.zddc — open employee project (technical) +acl: + allow: ["*@mycompany.com"] +``` + +```yaml +# /Acme-comm/.zddc — closed sibling (commercially sensitive) +acl: + allow: + - alice@mycompany.com + - bob@mycompany.com +``` + +```yaml +# /Archive/.zddc — employees can browse the vendor list +acl: + allow: ["*@mycompany.com"] +``` + +```yaml +# /Archive/Acme/.zddc — vendor's only window +acl: + allow: + - acme-rep@acme.com +``` + +Trace for an insider (`alice@mycompany.com`) and a vendor (`acme-rep@acme.com`) +hitting representative paths: + +| Path | alice@mycompany.com | acme-rep@acme.com | +|---|---|---| +| `/Acme-tech/` | ✅ matches `*@mycompany.com` at this level | ❌ no match anywhere; `HasAnyFile=true` → 403 | +| `/Acme-comm/` | ✅ matches `alice` at this level | ❌ default-deny | +| `//` | ❌ no match anywhere → 403 | ❌ default-deny | +| `/Archive/` | ✅ matches `*@mycompany.com` | ❌ no match; default-deny | +| `/Archive/Acme/` | ✅ falls up to `/Archive/.zddc`, matches | ✅ matches `acme-rep@acme.com` at this level | +| `/Archive/Acme/Incoming/` | ✅ inherits from `/Archive/.zddc` | ✅ inherits from `/Archive/Acme/.zddc` | +| `/Archive//` | ✅ inherits from `/Archive/` | ❌ no match; default-deny | +| `/` (project picker) | Lands; sees the projects she has access to | Lands; project picker filtering hides everything she can't reach | + +The vendor reaches `/Archive/Acme/...` only via direct URL (a bookmark or a +transmittal email link). Trying to navigate up to `/Archive/` returns 403; the +archive tool itself treats `/Archive/Acme/` as the root of *its* world (see +"Tool-rooted view" in `ARCHITECTURE.md` § Server security model), so there is +no breadcrumb leading anywhere they can't see. + +### Patterns that look secure but aren't + +These are the traps. Each is plausible at first glance and doesn't behave as +naive intuition suggests. + +1. **Same-level `allow + deny "*@company.com"` does NOT lock the level down to the + allow's targets.** Deny is checked before allow within a single `.zddc`, so the + allowed user's email matches the deny first and is blocked. To exclude insiders + from a leaf, use the two-level gate-and-reallow (parent denies, deeper level + re-allows) — *or* avoid putting `*@company.com` at any ancestor. + + ```yaml + # /Closed/.zddc — DOES NOT WORK as intended + acl: + allow: [alice@company.com] + deny: ["*@company.com"] # blocks alice too — deny is checked first at same level + ``` + +2. **A leaf-level `allow: [subset]` does NOT restrict when a parent has + `allow: ["*@domain"]`.** Non-subset users hit the leaf with no match, walk up, + match the parent wildcard, and are allowed. Adding a leaf allow is *additive*, + never *subtractive*. (See the asymmetry section above.) + +3. **`admins:` in any `.zddc` other than the root is silently ignored.** The check + at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is + the only upward-escalation gate; subtree write access never grants admin. + +4. **An `apps:` URL override is a full UI mount, not just a tool version pin.** + Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...` + and serve arbitrary HTML to every viewer below that level. Subtree write + authority on `.zddc` should be treated as full UI-mounting authority. The + `_app/` cache is fetch-once-and-keep — operators clear it by deleting + `/_app//`. (See "Apps: virtual tool HTMLs" below for + the resolver order; SHA-256 pinning is on the federal-readiness list, not + currently implemented.) + +5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders' + existence.** Sibling-vendor names are hidden because directories the caller + can't access are omitted from listings (see "Directory visibility" below) — + that's the actual mechanism. Don't rely on the parent dir being denied; rely + on the listing filter. + +### Trust model and invariants + +These are the invariants security reviewers should expect to find, stated plainly +and tied to the code that enforces them: + +- **Auth boundary.** zddc-server does not authenticate. The user's email is read + from the configured request header (default `X-Auth-Request-Email`) set by an + upstream reverse proxy. If the proxy is misconfigured or the bind address is + reachable without traversing the proxy, every request is anonymous — and any + caller able to set the header can claim any email. Network isolation between + the proxy and zddc-server is required (see "Trust boundary" below). +- **Subtree authority.** Whoever can write a `.zddc` controls that subtree's + ACL — including overriding a parent *deny* with a leaf *allow* (test: + `zddc/internal/zddc/acl_test.go:212` "leaf allows user that parent denies → + leaf wins"). This is intentional delegation, not a bug. If you grant write + access to `/Project-A/.zddc`, you've granted full ACL authority over the + Project-A subtree. +- **Root-only escalation gate.** `admins:` is honored *only* at + `/.zddc`. Subtree `admins:` entries are silently ignored + (`zddc/internal/zddc/file.go:17-20`). This is the *only* upward escalation + block; without it, anyone with subtree write authority could promote + themselves to admin. + +### Trust boundary + +What zddc-server enforces stops at the network boundary. The deployment must +guarantee these for the model above to hold: + +1. **The bind address must be reachable only via the authenticating proxy.** The + email-header trust assumes the proxy is the only path to the server. In + Kubernetes: a `NetworkPolicy` restricting ingress to the proxy pod. On a + single host: bind to loopback (`ZDDC_ADDR=127.0.0.1:8080`) and run the proxy + on the same host. **Without this**, anyone reaching the bind address can + forge any email by setting `X-Auth-Request-Email` themselves. zddc-server + refuses to start with `ZDDC_TLS_CERT=none` on a non-loopback bind unless + `ZDDC_INSECURE_DIRECT=1` is set as an explicit acknowledgement that an + authenticating proxy is enforcing this. +2. **Anonymous information disclosure on `/` is by design.** The public landing + page returns a project picker filtered by ACL — anonymous callers see only + projects with no `.zddc` rules along their chain (in a properly-configured + deployment, none). The *existence* of the server and the names of any + ACL-free projects are disclosed without authentication. For deployments + where this disclosure is unacceptable, gate `/` itself behind the proxy's + auth-required path; zddc-server's public-landing logic does not need to be + disabled, it simply never receives an anonymous request. `/.profile` is also + reachable anonymously by design — same caveat applies. +3. **Audit log integrity is filesystem-level, not application-level.** The + tee'd JSON access log lives at `/.zddc.d/logs/access-.log` + on the served volume. While `.`-prefixed paths return 404 over HTTP (so the + log is not readable through zddc-server), anything with **filesystem write + authority on the served volume** (a sidecar, a backup-restore process, an + admin shell) can modify log entries after the fact. For tamper-evident + logging, ship the JSON-line file to an external append-only sink (syslog, + SIEM) via a sidecar; do not treat the local rotation as the system of + record. +4. **`apps:` URL fetches have no integrity check.** Fetched once on first + miss, cached at `/_app//` forever — no SHA-256 pin, + no signature. Use only URLs you control, treat the apps cache as a trust + boundary, and audit who has `.zddc` write authority where. + +### Debugging permissions + +When a user reports "I can't see /Project-X/" and you need to figure out why, +manual cascade-tracing is the current path: + +1. **Confirm the resolved email** — hit `/.admin/whoami` as the user (you'll + need to have proxy auth working, or run the request *through* the proxy + that signs them in). The page shows every header on the request and the + `email` field zddc-server resolved. +2. **List the chain.** From `` down to the requested directory, + inspect each `.zddc` (most directories have none). For + `/Project-X/sub/sub/`, that's `/.zddc`, `/Project-X/.zddc`, + `/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists. +3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny + patterns first (any match → blocked), then allow (any match → allowed). + First explicit match in the bottom-up walk is the answer. Default-deny + if `HasAnyFile=true` and nothing matches. + +A built-in `/.admin/effective-policy?path=...&email=...` endpoint that does +this trace and returns the chain + decision is on the future-work list (see +below); until it ships, the manual procedure is the only path. + ### Directory visibility Directories for which the user lacks access are **omitted** from JSON listings entirely — they are neither listed nor queryable. A direct request to a denied path returns `403`. +This is the mechanism that hides sibling subtrees from a caller. Vendor `acme-rep` +sees an empty-looking `/Archive/` (in fact returns 403 since they're not allowed +there at all in the worked example), and no other vendor's name leaks via listing. + ### Reserved hidden segments Two prefixes are filtered from listings under `ZDDC_ROOT`: @@ -190,6 +459,318 @@ Two prefixes are filtered from listings under `ZDDC_ROOT`: browse to but might link to (e.g. a `_template/` directory of stub-HTML examples to copy into project subdirs). +### How to verify in 5 minutes + +This recipe stands up the worked-example layout in a tmpdir, hits each +(email, path) cell with `curl`, and asserts the documented behavior. Run it on +your own deployment to confirm the cascade is doing what you think: + +```sh +ROOT=$(mktemp -d) +mkdir -p "$ROOT/Acme-tech" "$ROOT/Acme-comm" "$ROOT/Archive/Acme" + +cat > "$ROOT/.zddc" <<'YAML' +admins: [admin@mycompany.com] +YAML +cat > "$ROOT/Acme-tech/.zddc" <<'YAML' +acl: {allow: ["*@mycompany.com"]} +YAML +cat > "$ROOT/Acme-comm/.zddc" <<'YAML' +acl: {allow: [alice@mycompany.com]} +YAML +cat > "$ROOT/Archive/.zddc" <<'YAML' +acl: {allow: ["*@mycompany.com"]} +YAML +cat > "$ROOT/Archive/Acme/.zddc" <<'YAML' +acl: {allow: [acme-rep@acme.com]} +YAML + +# Plain HTTP on loopback so curl doesn't need TLS +ZDDC_ROOT="$ROOT" ZDDC_TLS_CERT=none ZDDC_ADDR=127.0.0.1:8090 \ + ./zddc-server & +SERVER_PID=$! +sleep 1 + +probe() { # email path expected_status + got=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "X-Auth-Request-Email: $1" \ + "http://127.0.0.1:8090$2") + printf '%-40s %-30s expected=%s got=%s %s\n' \ + "$1" "$2" "$3" "$got" \ + "$([ "$got" = "$3" ] && echo OK || echo FAIL)" +} + +# Insider — alice should see all the technical + her closed project + Archive tree +probe alice@mycompany.com /Acme-tech/ 200 +probe alice@mycompany.com /Acme-comm/ 200 +probe alice@mycompany.com /Archive/ 200 +probe alice@mycompany.com /Archive/Acme/ 200 + +# Insider not on the closed project — bob should hit the technical and Archive, +# NOT the closed sibling +probe bob@mycompany.com /Acme-tech/ 200 +probe bob@mycompany.com /Acme-comm/ 403 +probe bob@mycompany.com /Archive/ 200 +probe bob@mycompany.com /Archive/Acme/ 200 + +# Vendor — acme-rep should ONLY see /Archive/Acme/, blocked everywhere else +probe acme-rep@acme.com /Acme-tech/ 403 +probe acme-rep@acme.com /Acme-comm/ 403 +probe acme-rep@acme.com /Archive/ 403 +probe acme-rep@acme.com /Archive/Acme/ 200 + +# Anonymous — root .zddc exists, so HasAnyFile=true → default-deny everywhere +# (the root / itself is the public-landing exception; subdirs are gated) +probe '' /Acme-tech/ 403 +probe '' /Archive/Acme/ 403 + +# Anti-pattern: same-level allow + deny *@company.com does NOT lock alice in +mkdir -p "$ROOT/Trap" +cat > "$ROOT/Trap/.zddc" <<'YAML' +acl: + allow: [alice@mycompany.com] + deny: ["*@mycompany.com"] # deny is checked first → blocks alice too +YAML +probe alice@mycompany.com /Trap/ 403 # the trap docs warn about + +kill $SERVER_PID +``` + +Every line should print `OK`. If any prints `FAIL`, the cascade isn't behaving +as documented — file an issue with the failing line. **After every `.zddc` +change in production**, retest at minimum as the editing user (to confirm you +haven't locked yourself out). + +### Federal-readiness gap analysis + +The current model is well-suited for commercial-tenant ACL with delegated +authentication. To clear federal hurdles (FedRAMP Moderate, NIST 800-53 Rev. 5 +baseline, FIPS 140-3, DoD STIG), these gaps would need to be closed. None are +implemented today — this list is informational so security reviewers don't +have to redo the gap analysis from scratch. + +- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib + crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS + userspace, or use the `microsoft/go` (formerly goboring) toolchain. +- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config` + defaults; no explicit `MinVersion`, `CipherSuites`, or curve list. + Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred), + DoD-approved cipher allowlist, OCSP stapling, HSTS header. +- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is + network-level isolation only. Required: mTLS or signed forwarding token + (e.g. JWT signed by the proxy with a key zddc-server validates). +- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream + proxy. Required: documented reference deployment with PIV/CAC via + oauth2-proxy or equivalent. +- **Role-based access control** (NIST AC-3(7)) — current model is per-email + allow/deny + a single root-admin role. Required: roles as first-class + entities, `.zddc` syntax for role grants, identity-source-driven role + assignment. +- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny + is incompatible with default federal expectations. Required: a configurable + enforcement mode where parent denies are absolute and only root admins can + override. +- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to + authoritative sources (PIV cert subject, IdP-managed identity). Required: + documented integration with at least one IdP supporting federal identity + attestation. +- **Audit log integrity & retention** (NIST AU-9, AU-11) — current 90-day + local rotation is a starting point. Required: tamper-evident logs (signed + log chain or external append-only sink) with 1-year minimum online and + 3-year archive. +- **Continuous monitoring hooks** (NIST CA-7) — automated alerting on + `.zddc` changes, admin endpoint use, repeated 403s from one identity. + Required: structured event emission to syslog/SIEM beyond the local file. +- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip, + docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline. + `apps:` URL fetches need SHA-256 pinning and signature verification. +- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment + platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with + key-rotation procedures. +- **Vulnerability disclosure process** (NIST SI-5) — repo lacks + `SECURITY.md`. Required: documented disclosure procedure, embargoed-fix + workflow, CVE-assignment policy. + +A full SSP / control-by-control mapping consumes this list as input; it is +not a substitute for one. + +### External policy decider (OPA-compatible) + +For deployments that need policy decisions made by an external, +independently-audited engine — typically federal customers using +[Open Policy Agent](https://www.openpolicyagent.org/) — zddc-server can +delegate every access decision to an HTTP/Unix-socket endpoint that +speaks OPA's `/v1/data/...` JSON wire protocol. + +Set one of: + +```sh +ZDDC_OPA_URL=internal # built-in Go evaluator (default) +ZDDC_OPA_URL=http://127.0.0.1:8181 # OPA via HTTP +ZDDC_OPA_URL=https://opa.internal:8181 # OPA via HTTPS +ZDDC_OPA_URL=unix:///run/opa/opa.sock # OPA via Unix socket +``` + +Internal mode uses zddc-server's in-process evaluator — same Go code that +backs the cascade rules above, no network round-trip, no external dependency. +This is the default. + +External mode POSTs each access decision to +`/v1/data/zddc/access/allow` with body: + +```json +{ + "input": { + "user": {"email": "alice@mycompany.com"}, + "path": "/Project-A/sub/", + "policy_chain": { + "levels": [ + {"acl": {}, "admins": ["admin@mycompany.com"]}, + {"acl": {"allow": ["*@mycompany.com"]}} + ], + "has_any_file": true + } + } +} +``` + +OPA evaluates the deployment's Rego policy against this input and returns: + +```json +{"result": true} +``` + +Rego policy authors can implement either: + +- **Same semantics as our internal evaluator** — walk + `input.policy_chain.levels` bottom-up, deny-first within a level, + default-deny when `input.policy_chain.has_any_file` is true. The + `.zddc` files in the served tree continue to drive policy unchanged. +- **Federal-mode tightening** — same chain, but parent denies are + absolute (no leaf-allow override of an ancestor's deny). NIST AC-6 + least-privilege posture. +- **RBAC-from-IdP** — read additional fields from `input.user` + (e.g. `roles` populated by the upstream proxy from SAML/OIDC claims) + and decide based on those, treating `.zddc` as a file-tree map of + required-roles instead of explicit emails. +- **Anything else** — Rego is general-purpose policy; once you're + running real OPA, the constraints are whatever you write. + +### OPA failure modes + +External mode adds a network call to the request hot path. zddc-server +treats unreachable / non-2xx / malformed-response cases as **deny** +(fail-closed) by default, with a `WARN` log. Operators who prefer +availability over correctness — typically not federal — can flip this: + +```sh +ZDDC_OPA_FAIL_OPEN=1 # allow on transport error +``` + +Always-WARN logging means a healthy run is silent and a sick OPA is +loud regardless of which mode you pick. + +### OPA decision cache + +External OPA mode wraps the HTTP/socket client in a small per-decision +cache (default 1 s TTL). A single `.archive` listing or directory +enumeration walks the cascade for every entry, which would otherwise +issue one OPA round-trip per entry; the cache collapses identical +`(email, decision-input)` tuples down to one call per TTL window. + +The 1-second default is short enough that a `.zddc` edit is reflected +in the next listing (it's the same window as the fsnotify watcher's +debounce). Operators who want zero staleness — or who are running +their own caching layer in front of OPA — can disable: + +```sh +ZDDC_OPA_CACHE_TTL=0 # no caching, every request → OPA +ZDDC_OPA_CACHE_TTL=5s # longer window for batchy workloads +``` + +The cache is per-process, in-memory, and capped at ~4096 entries with +opportunistic eviction of expired entries. Internal mode (the default) +gets no cache — the in-process Go evaluator is already cheaper than a +cache lookup would be. + +### Reference Rego policy + +The `--print-rego` flag emits the bundled reference Rego policy that +mirrors internal-mode semantics exactly. Federal customers standing up +their own OPA instance can use it as a starting point: + +```sh +zddc-server --print-rego > /etc/opa/policies/zddc-access.rego +``` + +Parity is enforced at build time. `zddc/internal/policy/parity_test.go` +imports the OPA Go module **as a test-only dependency**, evaluates the +bundled Rego against the same fixture set the internal Go evaluator +runs, and fails CI on any divergence. The test-only import means the +production binary stays OPA-free (still 13 MB) — the OPA library is in +`go.mod` but not in `go build`'s output. + +This gives you both ends of the spectrum: a single OPA-aware codebase +where the production decider is pure Go (no library bloat, no extra +process), the wire format is OPA-canonical (just point an external OPA +at it and decisions delegate seamlessly), and the bundled reference +Rego is a parity-tested artifact you can ship alongside or extend. + +Typical federal customizations on top of the bundled Rego: + +- **Parent-deny-is-absolute** — flip the leaf-allow-overrides-parent-deny + rule for NIST AC-6 least-privilege posture. +- **Role-based access** — read additional input fields like + `input.user.roles` populated by the upstream proxy from SAML/OIDC + claims, and decide based on those instead of (or alongside) email. +- **Time-of-day or IP-range constraints** — Rego can read + `input.context.now` and request metadata for context-aware + decisions. +- **SIEM-shipped decision logs** — OPA's logging plugins emit every + decision in a structured format ready for Splunk Government, Elastic + Federal, etc. + +### Reference deployment shapes + +**Commercial / default**: nothing to set. `ZDDC_OPA_URL=internal` is +the implicit default; the in-process evaluator handles every decision. +No sidecar, no extra port, no extra binary. + +**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar, +nomad task, or systemd service on the same host), bind it to +`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA +loads the deployment's bundled Rego policy from a configured source +(filesystem, signed bundle from S3, OPAL, etc.) and is patched +independently of zddc-server. + +**Per-tenant policy variants**: run multiple OPA instances each loaded +with a different bundle, point each zddc-server replica at the +appropriate one. The `.zddc` files in the served tree stay the same; +the *interpretation* of those files differs per tenant. + +### Future work + +Items the conversation flagged as friction in operator setup or as documented +gaps that warrant code, in addition to the federal-readiness items above: + +- `/.admin/effective-policy?path=...&email=...` endpoint returning the + resolved chain + decision, so debugging permissions stops requiring manual + cascade tracing. +- `.zddc.form.yaml` ACL editor (built on the form-data system) once + file-as-truth round-trip preserves comments — turn the manual YAML edit + into a self-service UI for project owners. +- Save-time validation that warns when a `.zddc` change would lock the + editing user out (or have a measurable effect they didn't anticipate). +- `zddc-server policy export` command emitting every `.zddc` file's resolved + effect, suitable for change-control review (and a prerequisite for the + CM-3 federal control above). +- Per-decision caching for external OPA mode (small TTL on (email, path) + to amortize the .archive listing's per-entry round-trip). +- A reference Rego bundle shipped alongside the binary that exactly + reproduces internal mode, plus a "federal-mode" variant that flips + the parent-deny-is-absolute toggle. Useful as a starting point for + customers who want to extend rather than write from scratch. + ## Admin Debug Page `zddc-server` exposes a built-in debug page at `/.admin/` for operators who can @@ -223,9 +804,10 @@ acl: - "*@mycompany.com" ``` -Only the root-level `admins` entry is honored — subdirectory `.zddc` files' -`admins` keys are ignored. Otherwise anyone with subtree write access could -elevate themselves. +The root-only invariant (subdirectory `admins:` entries are silently ignored, so +subtree write authority cannot be self-promoted to admin) is documented under +"Trust model and invariants" in the access-control section above — that's the +canonical home; this section just shows the syntax. If the root `.zddc` has no `admins` list (or no `.zddc` exists), every admin endpoint returns **404** to every caller. Non-admin requests also receive 404 @@ -345,6 +927,42 @@ server-side state required. users can email direct links to a pre-filtered view. If the recipient does not have access to a project listed in the URL, a warning banner is shown. +## Caching and ETags + +zddc-server uses content-hash ETags wherever a re-fetch of identical +content is plausible — directory listings, the project list, the +embedded HTML tools. + +| Endpoint | ETag source | Notes | +|---|---|---| +| `GET /` (project list, `Accept: application/json`) | SHA-256 prefix of the response body | Refetched on every request; the JSON is rebuilt from current FS state. The hash is the actual response, so 304s are always trustworthy regardless of filesystem-watcher reliability. | +| `GET //` (directory listing, `Accept: application/json`) | SHA-256 prefix of the response body | Same. | +| `GET //` (HTML browse) | Hash of the embedded `browse.html` template | Computed once at startup, memoized. Changes only on binary redeploy. | +| `GET /.html` (embedded tools) | Hash of the embedded bytes | Same — memoized at startup. | + +**Why content-hash and not server-side caching?** The cascade walks +`.zddc` files on every directory access; an `os.ReadDir` runs to build +the listing; the ACL filter applies. A cache keyed on directory mtime +would save that server work but depends on reliable filesystem +watching — and Azure Files SMB mounts (a common deployment substrate) +do not support `inotify`/`fsnotify` reliably. Content-hash ETags +deliver only the bandwidth savings, not the server-work savings, but +they cannot lie about staleness regardless of watcher behavior. A +future enhancement could add an mtime-keyed cache for environments +with reliable watchers, behind a feature flag. + +The response headers are: + +``` +Cache-Control: private, max-age=0, must-revalidate +ETag: "<16-hex-char hash>" +``` + +`must-revalidate` ensures every refresh round-trips the server (which +re-runs the cascade and ACL filter); `max-age=0` means no client-side +freshness window; `private` prevents intermediary caches from sharing +responses across users (each user has their own ACL-filtered view). + ## Access Logging Every HTTP request is logged as a structured `slog` entry at `INFO` level: @@ -481,7 +1099,7 @@ Downstream Helm charts and Compose files should set these explicitly: | `ZDDC_INSECURE_DIRECT` | `1` | Acknowledge plain HTTP behind a trusted proxy | | `ZDDC_ADDR` | `:8080` | Match service / probe port | | `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header your auth proxy sets | -| `ZDDC_CORS_ORIGIN` | `https://your-host` | Origins permitted to call back into the server | +| `ZDDC_CORS_ORIGIN` | *(unset)* | Leave unset for embedded-tools deployments (same-origin); set to your tool host (`https://tools.acme.com`) only for self-hosted-tools or CDN-bootstrap layouts | See "Environment Variables" above for the full list.