docs: ACL/security overhaul (cascade rules, OPA, caching)
Three docs aligned with the preceding three feature commits. zddc/README.md -------------- Major overhaul of the access-control narrative. The previous "three- tier" example table was misleading: it claimed a project-level allow-list "restricts" access under a parent wildcard, when actually the cascade is additive (a non-team employee falls up to root and matches *@company.com). Operators reading the old docs would build deployments that looked locked-down but leaked across the company. New sections under "Access control: the .zddc cascade": * Step 1: starter .zddc — leads with the public-by-default warning and the --insecure escape hatch * How a request is evaluated — bottom-up walk with code citations * Glob patterns — @-boundary rule * When the cascade helps and when it fights you — the asymmetry between adding strangers (easy) and excluding insiders (hard) * Pick your layout — decision matrix for common shapes * Worked example: paired open/closed projects + third-party archive — full layout with trace table for two representative users * Patterns that look secure but aren't — anti-patterns including same-level allow+deny shadow, leaf-allow-doesn't-restrict, apps:-as-UI-mount * Trust model and invariants — auth boundary, subtree authority, root-only escalation gate * Trust boundary — network isolation requirement, anonymous information disclosure on /, audit-log integrity * Debugging permissions — manual cascade trace * Directory visibility / Reserved hidden segments * How to verify in 5 minutes — recipe with negative anti-pattern test * Federal-readiness gap analysis — bulleted with NIST control refs * External policy decider — OPA wire format, deployment shapes, failure modes * OPA decision cache — TTL semantics, knobs * Reference Rego policy — --print-rego, parity test rationale * Caching and ETags — content-hash story, why not server-side * Future work Plus env-var table updates for ZDDC_INSECURE, ZDDC_OPA_URL, ZDDC_OPA_FAIL_OPEN, ZDDC_OPA_CACHE_TTL; CORS narrative reflects default-empty. ARCHITECTURE.md --------------- New "Server security model" section between Form Renderer and CSS: cooperating layers (auth / policy decider / cascade / tool-rooted view / reserved prefixes / audit log), commercial-vs-federal trust model side-by-side, why the tool-rooted view matters for third-party containment. AGENTS.md --------- Two new env-var rows (ZDDC_OPA_URL, ZDDC_OPA_CACHE_TTL); ACL line sharpened with cascade rules + cross-reference; ZDDC_CORS_ORIGIN description updated for default-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a01315fd00
commit
5c33c8a821
3 changed files with 729 additions and 40 deletions
|
|
@ -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_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_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
|
||||||
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
|
| `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_ROOT>/.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_ROOT>/.zddc.d/logs/access-<host>.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. |
|
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.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
|
### 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+)
|
- 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
|
- 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: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+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.
|
- 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: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+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".
|
- `.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.
|
- `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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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_ROOT>/.zddc.d/logs/access-<host>.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/<their-name>/`" the
|
||||||
|
out-of-the-box behavior with no per-deployment configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CSS Architecture
|
## CSS Architecture
|
||||||
|
|
||||||
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
|
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
|
||||||
|
|
|
||||||
694
zddc/README.md
694
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_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_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_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_ROOT>/.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_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||||
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
|
| `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_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_ROOT>/.zddc.d/logs/access-<host>.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_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.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.
|
`ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates.
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
The default `ZDDC_CORS_ORIGIN=https://zddc.varasys.io` exists so the canonical
|
CORS is **disabled by default** — `ZDDC_CORS_ORIGIN` defaults to empty.
|
||||||
ZDDC tool builds (hosted at `zddc.varasys.io`) can call back into your
|
The embedded-tools install path serves tools and data from the same origin
|
||||||
deployed `zddc-server` without extra configuration. If you self-host the
|
(both come from zddc-server itself), so no cross-origin allowlist is needed
|
||||||
tools on your own domain (e.g. `tools.acme.com`), set:
|
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
|
```sh
|
||||||
|
# Self-hosted tools on a separate domain
|
||||||
ZDDC_CORS_ORIGIN=https://tools.acme.com
|
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
|
Multiple origins are comma-separated. The middleware echoes the matched
|
||||||
all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The
|
origin back per-request and sets `Access-Control-Allow-Credentials: true`
|
||||||
middleware echoes the matched origin back per-request and sets
|
so the upstream-set `X-Auth-Request-Email` header crosses the boundary.
|
||||||
`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
|
## 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
|
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.
|
(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
|
If the header is absent, the user is treated as anonymous (empty email). A request is
|
||||||
no `.zddc` rules is publicly accessible; a directory with an allowlist requires a matching
|
allowed only if (a) **no `.zddc` file exists anywhere in the chain from `ZDDC_ROOT` to
|
||||||
email.
|
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
|
> ⚠️ **zddc-server refuses to start without a root `.zddc`.** A `ZDDC_ROOT` containing
|
||||||
directories — child rules are appended to (not replaced by) parent rules.
|
> 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_ROOT>/.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
|
```yaml
|
||||||
# Example .zddc file
|
# Example .zddc file
|
||||||
acl:
|
acl:
|
||||||
allow:
|
allow:
|
||||||
- "*@mycompany.com" # all users at mycompany.com
|
- "*@mycompany.com" # everyone at mycompany.com
|
||||||
- "contractor@partner.com" # specific external user
|
- "contractor@partner.com" # specific external user
|
||||||
deny:
|
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
|
Every install should write a root `.zddc` before exposing the bind address. The
|
||||||
toward the root. The first explicit match (allow or deny) at any level wins.
|
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**
|
```yaml
|
||||||
2. Check allow patterns at the current level — if email matches → **allow**
|
# <ZDDC_ROOT>/.zddc — bare minimum
|
||||||
3. No match at this level → walk up to parent directory and repeat
|
admins:
|
||||||
4. If no `.zddc` files were found anywhere in the chain → **allow** (public, no rules)
|
- you@yourcompany.com
|
||||||
5. If `.zddc` files exist but email matched nothing → **403 Forbidden** (not on any list)
|
```
|
||||||
|
|
||||||
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 |
|
### How a request is evaluated
|
||||||
|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
A vendor navigating to their subdirectory is allowed by the deepest matching rule,
|
When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along
|
||||||
even if a higher-level rule would deny them.
|
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
|
### Glob patterns
|
||||||
|
|
||||||
|
|
@ -171,11 +218,233 @@ even if a higher-level rule would deny them.
|
||||||
| `*` | Any non-empty email |
|
| `*` | Any non-empty email |
|
||||||
| `alice@example.com` | Exact match only |
|
| `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 `<tenant>/.zddc` with `acl: {allow: [<tenant emails>]}`. 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_ROOT>/.zddc — admins only, no broad ACL
|
||||||
|
admins:
|
||||||
|
- admin@mycompany.com
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# <ZDDC_ROOT>/Acme-tech/.zddc — open employee project (technical)
|
||||||
|
acl:
|
||||||
|
allow: ["*@mycompany.com"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# <ZDDC_ROOT>/Acme-comm/.zddc — closed sibling (commercially sensitive)
|
||||||
|
acl:
|
||||||
|
allow:
|
||||||
|
- alice@mycompany.com
|
||||||
|
- bob@mycompany.com
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# <ZDDC_ROOT>/Archive/.zddc — employees can browse the vendor list
|
||||||
|
acl:
|
||||||
|
allow: ["*@mycompany.com"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# <ZDDC_ROOT>/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 |
|
||||||
|
| `/<other-closed-project>/` | ❌ 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/<other-vendor>/` | ✅ 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
|
||||||
|
`<ZDDC_ROOT>/_app/<host>/<path>`. (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_ROOT>/.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_ROOT>/.zddc.d/logs/access-<host>.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 `<ZDDC_ROOT>/_app/<host>/<path>` 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 `<ZDDC_ROOT>` 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
|
### Directory visibility
|
||||||
|
|
||||||
Directories for which the user lacks access are **omitted** from JSON listings entirely —
|
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`.
|
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
|
### Reserved hidden segments
|
||||||
|
|
||||||
Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
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
|
browse to but might link to (e.g. a `_template/` directory of stub-HTML examples
|
||||||
to copy into project subdirs).
|
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
|
||||||
|
`<ZDDC_OPA_URL>/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
|
## Admin Debug Page
|
||||||
|
|
||||||
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
||||||
|
|
@ -223,9 +804,10 @@ acl:
|
||||||
- "*@mycompany.com"
|
- "*@mycompany.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the root-level `admins` entry is honored — subdirectory `.zddc` files'
|
The root-only invariant (subdirectory `admins:` entries are silently ignored, so
|
||||||
`admins` keys are ignored. Otherwise anyone with subtree write access could
|
subtree write authority cannot be self-promoted to admin) is documented under
|
||||||
elevate themselves.
|
"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
|
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
|
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
|
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.
|
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 /<dir>/` (directory listing, `Accept: application/json`) | SHA-256 prefix of the response body | Same. |
|
||||||
|
| `GET /<dir>/` (HTML browse) | Hash of the embedded `browse.html` template | Computed once at startup, memoized. Changes only on binary redeploy. |
|
||||||
|
| `GET /<app>.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
|
## Access Logging
|
||||||
|
|
||||||
Every HTTP request is logged as a structured `slog` entry at `INFO` level:
|
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_INSECURE_DIRECT` | `1` | Acknowledge plain HTTP behind a trusted proxy |
|
||||||
| `ZDDC_ADDR` | `:8080` | Match service / probe port |
|
| `ZDDC_ADDR` | `:8080` | Match service / probe port |
|
||||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header your auth proxy sets |
|
| `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.
|
See "Environment Variables" above for the full list.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue