Compare commits

...

4 commits

Author SHA1 Message Date
5c33c8a821 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>
2026-05-04 17:46:57 -05:00
a01315fd00 feat(server): reference Rego, parity test, decision cache, listing ETags
Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.

Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).

Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:

    zddc-server --print-rego > /etc/opa/policies/zddc-access.rego

Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.

The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.

External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).

ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.

Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).

Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:24 -05:00
e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00
6b973906c3 feat(server): refuse to start without root .zddc; default CORS to empty
Two safe-by-default flips, both opt-out via explicit acknowledgement.

1. --insecure / ZDDC_INSECURE=1: zddc-server now refuses to start when
   no <ZDDC_ROOT>/.zddc exists. With no .zddc anywhere in the chain,
   AllowedWithChain falls through to "HasAnyFile=false → allow" and
   the tree is publicly accessible to anonymous callers — almost never
   what an operator wants on a fresh deployment, and previously a
   silent footgun. The flag is the escape hatch for deliberately-
   public archives (no .zddc anywhere by design).

2. ZDDC_CORS_ORIGIN now defaults to empty (CORS disabled) instead of
   the canonical "https://zddc.varasys.io". The embedded-tools install
   path serves tools and data same-origin, so the default never needed
   to permit cross-origin XHRs from a third-party host. Every deployment
   was implicitly trusting zddc.varasys.io to make authenticated XHRs
   on behalf of every logged-in user; if that origin were ever
   compromised, the blast radius extended to every customer server.
   Operators who deliberately use the CDN-bootstrap pattern or self-
   hosted tools at a different host now set the value explicitly.

Helm chart values updated accordingly: prod default is empty; dev
keeps localhost:8000 for tool-iteration workflows. Existing deployments
that depended on the old defaults will need to either set the value
explicitly or pass --insecure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:40:34 -05:00
24 changed files with 2301 additions and 101 deletions

View file

@ -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_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. |
### 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: `<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".
- `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.

View file

@ -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
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).

View file

@ -22,7 +22,11 @@ zddc:
rootPath: /srv
addr: ":8080"
emailHeader: X-Auth-Request-Email
corsOrigin: "https://zddc.varasys.io,http://localhost:8000"
# Empty (default) disables CORS — fine for embedded-tools / same-origin.
# Dev typically keeps localhost in here for the iterate-on-tool-builds
# workflow where you load a tool from `./dev-server start` (8000) and
# point it at this server. Add other tool-host origins as needed.
corsOrigin: "http://localhost:8000"
logLevel: debug # full request headers logged; sensitive!
indexPath: ".archive"

View file

@ -27,9 +27,13 @@ zddc:
# Email-header convention from your authenticating reverse proxy.
emailHeader: X-Auth-Request-Email
# Comma-separated CORS allowlist. Set to your tools host, or empty
# to disable CORS entirely (when tools are same-origin).
corsOrigin: "https://zddc.varasys.io"
# Comma-separated CORS allowlist. Empty (default) disables CORS —
# appropriate for the embedded-tools install path where tools are
# served same-origin by zddc-server itself. Set to a specific origin
# only if browser-loaded pages from a different host call back into
# this server (e.g. self-hosted tools at https://tools.acme.com,
# or the CDN-bootstrap pattern from https://zddc.varasys.io).
corsOrigin: ""
# info / warn / error / debug. Production stays on info; debug logs
# every request's full header map (includes cookies/auth tokens).

View file

@ -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_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_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_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.
### 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_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
# 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_ROOT>/.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 `<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
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
`<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
`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 /<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
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.

View file

@ -18,6 +18,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -31,6 +32,16 @@ import (
var version = "dev"
func main() {
// --print-rego: dump the bundled reference Rego policy and exit.
// Cheap escape hatch for operators standing up an external OPA who want
// the parity-tested baseline as a starting point for customization.
for _, a := range os.Args[1:] {
if a == "--print-rego" {
fmt.Print(policy.ReferenceRego)
return
}
}
cfg, err := config.Load(os.Args[1:])
if errors.Is(err, config.ErrHelpRequested) {
config.Usage(os.Stderr)
@ -110,7 +121,32 @@ func main() {
// CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler.
auditLogger := setupAccessAuditLog(cfg.AccessLog)
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Construct the policy decider once at startup. ZDDC_OPA_URL=internal
// (default) routes decisions through the in-process Go evaluator;
// http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
}
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper").
if deciderCfg.CacheTTL == 0 {
deciderCfg.CacheTTL = -1
}
decider, err := policy.New(deciderCfg)
if err != nil {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1)
}
slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL)
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r)
})))))
@ -187,6 +223,23 @@ func main() {
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access.
// policyModeLabel collapses cfg.OPAURL to a one-word mode label for the
// startup log so operators can grep for the active decider quickly.
func policyModeLabel(opaURL string) string {
switch {
case opaURL == "" || strings.EqualFold(opaURL, "internal"):
return "internal"
case strings.HasPrefix(opaURL, "unix://"):
return "external-unix"
case strings.HasPrefix(opaURL, "https://"):
return "external-https"
case strings.HasPrefix(opaURL, "http://"):
return "external-http"
default:
return "unknown"
}
}
func setupAccessAuditLog(path string) *slog.Logger {
if path == "" {
return nil
@ -424,7 +477,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -449,7 +502,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -464,7 +517,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -5,8 +5,40 @@ go 1.24
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/klauspost/compress v1.18.6
github.com/open-policy-agent/opa v0.70.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.26.0 // indirect
require (
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/agnivade/levenshtein v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tchap/go-patricia/v2 v2.3.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

View file

@ -1,12 +1,158 @@
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U=
github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View file

@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
// Config holds all runtime configuration. Each field can be set via a
@ -23,8 +24,12 @@ type Config struct {
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -72,9 +77,17 @@ func Load(args []string) (Config, error) {
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
"HTTP header carrying the authenticated user's email.")
corsOriginFlag := fs.String("cors-origin", "",
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
"Comma-separated CORS allowlist. Empty (default) = CORS disabled. Set to your tool-host origin (e.g. https://tools.acme.com) only if browser-loaded pages from that origin call back into this server.")
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1",
"Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
opaURLFlag := fs.String("opa-url", getEnv("ZDDC_OPA_URL", "internal"),
"Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".")
opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -127,6 +140,10 @@ func Load(args []string) (Config, error) {
EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
}
// Default Root to the current working directory.
@ -146,6 +163,24 @@ func Load(args []string) (Config, error) {
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
}
// Refuse to start when the served tree has no root .zddc file. With no
// .zddc anywhere in the chain, AllowedWithChain falls through to its
// "HasAnyFile=false → allow" default, so every directory is publicly
// accessible to anonymous callers. The vast majority of operators do not
// want that — and the few who do (a deliberately public archive) can pass
// --insecure to acknowledge it. See zddc/README.md § Access control.
if !cfg.Insecure {
if _, err := os.Stat(filepath.Join(cfg.Root, ".zddc")); os.IsNotExist(err) {
return Config{}, fmt.Errorf(
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
"Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+
"or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment",
cfg.Root)
} else if err != nil {
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
}
}
// Audit-log default: if neither flag nor env was explicitly set,
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
// server captures an audit trail by default. Setting the flag/env
@ -211,8 +246,12 @@ func Usage(w io.Writer) {
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.")
@ -222,7 +261,14 @@ func Usage(w io.Writer) {
// resolveCORS implements the precedence rules for the CORS allowlist:
// - flag explicitly set → use flag value (empty = disabled)
// - else env var explicitly set → use env value (empty = disabled)
// - else → default to the canonical upstream
// - else → default to nil (CORS disabled)
//
// Default-empty is intentional: the embedded-tools deployment path (the install
// default) serves tools and data from the same origin, so CORS is unneeded.
// Operators who deliberately load tools from a different origin (e.g. the
// CDN-bootstrap pattern from https://zddc.varasys.io, or self-hosted at
// https://tools.acme.com) opt in by setting the value explicitly. This avoids
// implicit cross-origin trust on third-party domains.
func resolveCORS(flagSet bool, flagValue string) []string {
if flagSet {
return parseCSV(flagValue)
@ -230,7 +276,7 @@ func resolveCORS(flagSet bool, flagValue string) []string {
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
return parseCSV(v)
}
return []string{"https://zddc.varasys.io"}
return nil
}
// parseCSV splits a comma-separated list and trims whitespace. Empty
@ -276,3 +322,16 @@ func getEnv(key, fallback string) string {
}
return fallback
}
// parseDurationOrDefault parses a duration string ("1s", "500ms", "0", etc.).
// Returns def on empty input or parse error. Used for env-var defaults
// that need a sensible fallback rather than a hard error on typo.
func parseDurationOrDefault(s string, def time.Duration) time.Duration {
if s == "" {
return def
}
if d, err := time.ParseDuration(s); err == nil {
return d
}
return def
}

View file

@ -35,11 +35,17 @@ func TestIsLoopbackAddr(t *testing.T) {
func TestLoad(t *testing.T) {
root := t.TempDir()
// Drop a placeholder .zddc so subtests using this root don't trip the
// "no root .zddc → refuse to start" safety check. Tests that explicitly
// exercise the missing-.zddc path use a dedicated tmpdir without one.
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
t.Fatalf("seed root .zddc: %v", err)
}
// Pre-set the env so each subtest can override what it needs.
type envSet map[string]string
clearAll := func() {
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_INSECURE", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
os.Unsetenv(k)
}
}
@ -59,7 +65,10 @@ func TestLoad(t *testing.T) {
}{
{
name: "missing root defaults to CWD",
env: envSet{},
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test
// is specifically about Root resolution falling back to CWD, not
// about the .zddc safety check.
env: envSet{"ZDDC_INSECURE": "1"},
// ZDDC_ROOT not set → Load falls back to os.Getwd().
check: func(t *testing.T, cfg Config) {
cwd, err := os.Getwd()
@ -95,8 +104,8 @@ func TestLoad(t *testing.T) {
if cfg.EmailHeader != "X-Auth-Request-Email" {
t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" {
t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins)
if len(cfg.CORSOrigins) != 0 {
t.Errorf("CORSOrigins = %v, want empty (CORS disabled by default)", cfg.CORSOrigins)
}
},
},
@ -237,10 +246,23 @@ func TestLoad(t *testing.T) {
}
}
// tmpRootWithZddc creates a temp dir and seeds a minimal .zddc so the
// post-Root-stat safety check (refuse to start with no root .zddc) does
// not fire. Tests that exercise the safety check explicitly use
// t.TempDir() directly without seeding.
func tmpRootWithZddc(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
t.Fatalf("seed root .zddc: %v", err)
}
return dir
}
// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var.
func TestLoadFlags_OverrideEnv(t *testing.T) {
envRoot := t.TempDir()
flagRoot := t.TempDir()
envRoot := tmpRootWithZddc(t)
flagRoot := tmpRootWithZddc(t)
os.Setenv("ZDDC_ROOT", envRoot)
defer os.Unsetenv("ZDDC_ROOT")
@ -255,7 +277,7 @@ func TestLoadFlags_OverrideEnv(t *testing.T) {
// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults.
func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
root := t.TempDir()
root := tmpRootWithZddc(t)
cfg, err := Load([]string{
"--root", root,
"--addr", "127.0.0.1:9999",
@ -282,7 +304,7 @@ func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS.
func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) {
root := t.TempDir()
root := tmpRootWithZddc(t)
cfg, err := Load([]string{"--root", root, "--cors-origin", ""})
if err != nil {
t.Fatalf("Load: %v", err)
@ -311,6 +333,10 @@ func TestLoadFlags_VersionRequested(t *testing.T) {
// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD.
func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
os.Unsetenv("ZDDC_ROOT")
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test is
// specifically about the CWD fallback, not the .zddc safety check.
os.Setenv("ZDDC_INSECURE", "1")
defer os.Unsetenv("ZDDC_INSECURE")
cfg, err := Load([]string{})
if err != nil {
t.Fatalf("Load: %v", err)
@ -320,3 +346,47 @@ func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd)
}
}
// TestLoad_MissingRootZddcRefusesStartByDefault: with no .zddc at root and no
// --insecure, Load refuses to start (the public-by-default footgun).
func TestLoad_MissingRootZddcRefusesStartByDefault(t *testing.T) {
root := t.TempDir() // no .zddc seeded
os.Setenv("ZDDC_ROOT", root)
defer os.Unsetenv("ZDDC_ROOT")
_, err := Load([]string{})
if err == nil {
t.Fatal("Load() = nil error, want error about missing root .zddc")
}
if !strings.Contains(err.Error(), ".zddc") || !strings.Contains(err.Error(), "publicly accessible") {
t.Errorf("Load() error = %v, want substring about missing .zddc and public access", err)
}
}
// TestLoad_MissingRootZddcAllowedWithInsecure: --insecure allows startup
// when the root .zddc is missing (acknowledges the public-tree shape).
func TestLoad_MissingRootZddcAllowedWithInsecure(t *testing.T) {
root := t.TempDir() // no .zddc seeded
os.Setenv("ZDDC_ROOT", root)
os.Setenv("ZDDC_INSECURE", "1")
defer os.Unsetenv("ZDDC_ROOT")
defer os.Unsetenv("ZDDC_INSECURE")
cfg, err := Load([]string{})
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if !cfg.Insecure {
t.Errorf("cfg.Insecure = false, want true (set via ZDDC_INSECURE=1)")
}
}
// TestLoad_MissingRootZddcAllowedWithInsecureFlag: same but via --insecure flag.
func TestLoad_MissingRootZddcAllowedWithInsecureFlag(t *testing.T) {
root := t.TempDir() // no .zddc seeded
cfg, err := Load([]string{"--root", root, "--insecure"})
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if !cfg.Insecure {
t.Errorf("cfg.Insecure = false, want true (set via --insecure)")
}
}

View file

@ -1,12 +1,14 @@
package fs
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -30,7 +32,14 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// - dirPath="" means the root of the served tree
//
// baseURL should end with "/" and is the URL prefix for this directory.
func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
//
// The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider.
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
absDir, ok := safeJoin(fsRoot, dirPath)
if !ok {
return nil, os.ErrNotExist
@ -62,7 +71,12 @@ func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileIn
// ACL check for subdirectory
subAbs := filepath.Join(absDir, name)
chain, err := zddc.EffectivePolicy(fsRoot, subAbs)
if err != nil || !zddc.AllowedWithChain(chain, userEmail) {
if err != nil {
continue
}
subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
if !allowed {
continue // omit denied directories silently
}
fi := listing.FileInfo{

View file

@ -10,6 +10,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -32,6 +33,8 @@ import (
// filename: the part after .archive/ (empty for directory listing)
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
project := projectFromContextPath(contextPath)
if project == "" {
@ -48,7 +51,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -72,7 +75,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
@ -95,6 +98,8 @@ func projectFromContextPath(contextPath string) string {
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
decider := DeciderFromContext(r)
ctx := r.Context()
allEntries := idx.AllEntries(project)
archiveBase := contextPath
if !strings.HasSuffix(archiveBase, "/") {
@ -117,7 +122,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
aclCache[fileDir] = false
return false
}
v := zddc.AllowedWithChain(chain, email)
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
aclCache[fileDir] = v
return v
}

View file

@ -1,6 +1,8 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
@ -11,9 +13,19 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// listingETag returns a hex-encoded SHA-256 prefix of the rendered JSON
// listing body. Truncated to 16 chars (64 bits) — collisions on a
// listing of any realistic size are vanishingly unlikely, and the short
// header keeps the wire footprint trim.
func listingETag(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:8])
}
// safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot.
// Returns ("", false) if relPath would escape fsRoot.
func safeJoin(fsRoot, relPath string) (string, bool) {
@ -35,6 +47,8 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
// Compute relative dir path (no leading or trailing slash)
dirPath := strings.TrimPrefix(urlPath, "/")
@ -54,9 +68,11 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
isRoot := dirPath == ""
if !isRoot && !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
accept := r.Header.Get("Accept")
@ -73,7 +89,7 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Build base URL for listing entries
baseURL := urlPath // relative URLs suffice for JSON listings
entries, err := appfs.ListDirectory(cfg.Root, dirPath, email, baseURL)
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)
@ -92,11 +108,32 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Vary", "Accept")
if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(entries); err != nil {
// Content-hash ETag on the listing payload. Re-fetched on every
// request (the cascade is walked, ACL filter applied, JSON
// rendered, hashed) — that's the same server work the previous
// no-cache version did. The win is on the *response*: identical
// listings (e.g. the same vendor refreshing their archive page)
// short-circuit to 304 with no body.
//
// Crucially, this scheme tolerates unreliable filesystem
// watching (Azure SMB, network shares with delayed inotify):
// the ETag is the actual response hash, not a watcher-derived
// invalidation token, so it can never lie about content.
body, err := json.Marshal(entries)
if err != nil {
slog.Error("encoding directory listing", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
return
}

View file

@ -34,6 +34,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
@ -187,7 +188,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -263,7 +264,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -346,7 +347,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -6,6 +6,7 @@ import (
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"log/slog"
)
@ -14,10 +15,19 @@ type contextKey string
// EmailKey is the context key for the authenticated user's email.
const EmailKey contextKey = "email"
// DeciderKey is the context key for the request's policy decider.
// Set by ACLMiddleware so handlers deep in the stack can issue policy
// queries without taking the decider as an explicit parameter. Although
// the decider is an app-wide singleton (not per-request state), routing
// it through context keeps the call-site signatures stable across the
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ACLMiddleware extracts the user email from the configured header and stores
// it in the request context. It does NOT enforce ACL itself — each handler
// performs its own ACL check via zddc.EffectivePolicy / zddc.AllowedWithChain.
func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
// it (along with the policy decider) in the request context. It does NOT
// enforce ACL itself — each handler performs its own ACL check via
// policy.AllowFromChain.
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := r.Header.Get(cfg.EmailHeader)
// DEBUG-level header dump for diagnosing proxy / SSO header
@ -33,6 +43,9 @@ func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
"observed", email,
"headers", r.Header)
ctx := context.WithValue(r.Context(), EmailKey, email)
if decider != nil {
ctx = context.WithValue(ctx, DeciderKey, decider)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -45,6 +58,17 @@ func EmailFromContext(r *http.Request) string {
return ""
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
// keeps test setups that don't install ACLMiddleware functional.
func DeciderFromContext(r *http.Request) policy.Decider {
if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
return v
}
return &policy.InternalDecider{}
}
// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
type responseWriter struct {
http.ResponseWriter

View file

@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated.
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set.
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
})
// Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, noop))
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, AccessLogMiddleware(auditLogger, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")

View file

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
@ -9,6 +10,7 @@ import (
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -40,7 +42,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(cfg, email))
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
@ -85,13 +87,13 @@ type AccessView struct {
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint.
func enumerateAccess(cfg config.Config, email string) AccessView {
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
view := AccessView{
Email: email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
}
view.Projects, _ = EnumerateProjects(cfg, email)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
for _, t := range view.AdminSubtrees {

View file

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@ -9,6 +10,7 @@ import (
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -26,24 +28,44 @@ type ProjectInfo struct {
// ServeProjectList handles GET / with Accept: application/json.
// It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo.
//
// Response carries a content-hash ETag. The landing page polls this
// endpoint on every paint, and the response (a small JSON array of
// project names + URLs the caller can reach) rarely changes between
// polls, so 304s save a meaningful amount of cumulative bandwidth.
// The hash is computed from the actual response body, so it tolerates
// unreliable filesystem watching.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
projects, err := EnumerateProjects(cfg, EmailFromContext(r))
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(projects); err != nil {
body, err := json.Marshal(projects)
if err != nil {
slog.Error("encoding project list", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}
// EnumerateProjects returns the visible top-level projects for the given
// caller, reusing the same access logic as ServeProjectList. Exported so
// the profile page can render the same list server-side without an HTTP
// round-trip.
func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
// round-trip. A nil decider falls back to the internal Go evaluator.
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
entries, err := os.ReadDir(cfg.Root)
if err != nil {
slog.Error("reading root directory", "err", err)
@ -69,7 +91,7 @@ func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
if err != nil {
slog.Warn("ACL policy error", "path", absPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
continue
}
// Title comes from <project>/.zddc — optional, ignored on parse error.

View file

@ -0,0 +1,164 @@
package policy
import (
"context"
"encoding/json"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestRegoParity_AllInternalCases validates that the bundled reference
// Rego (ReferenceRego, embedded from rego/access.rego) produces the same
// allow/deny decision as the InternalDecider for every fixture below.
//
// This test imports the OPA Go module — but only in a _test.go file, so
// it does NOT end up in `go build ./cmd/zddc-server`'s production
// binary. Operators get a 13-MB OPA-free binary; CI gets a parity check
// that catches semantic drift between the two implementations the moment
// it occurs.
//
// The fixture set is intentionally inherited from acl_test.go so any new
// case added there (or a case the conversation surfaces while the docs
// are being maintained) is automatically picked up here.
func TestRegoParity_AllInternalCases(t *testing.T) {
ctx := context.Background()
// Compile the reference Rego once; query it for each fixture.
preparedQuery, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("rego compilation failed: %v", err)
}
allow := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}}
}
deny := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}}
}
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
path string
}{
// All canonical cascade cases from acl_test.go — kept as a parallel
// list rather than imported because acl_test.go's helpers aren't
// exported and we want this file to be self-contained.
{"empty chain no files", zddc.PolicyChain{HasAnyFile: false}, "alice@example.com", "/"},
{"empty chain no files, anon", zddc.PolicyChain{HasAnyFile: false}, "", "/"},
{"files exist no rule matches", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"leaf allow wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf deny beats parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), deny("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf no rule, parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allow("bob@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf re-allows what parent denied", zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com"), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"multi-level deepest wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/sub/"},
{"empty levels, files present, deny", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"empty levels, no files, allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: false}, "alice@example.com", "/sub/"},
// Glob-pattern parity — exercises email_matches in Rego against
// MatchesPattern in Go.
{"wildcard local part", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@*")}, HasAnyFile: true}, "alice@anywhere.com", "/"},
{"wildcard domain", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true}, "anyone@example.com", "/"},
{"exact match", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"exact does not match anyone else", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "bob@example.com", "/"},
{"wildcard does NOT cross @", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"bare * matches anyone", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*")}, HasAnyFile: true}, "alice@example.com", "/"},
// Worked-example layout traces (verify-it recipe in zddc/README.md).
// Insider on technical project.
{"insider on technical", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
}, HasAnyFile: true}, "alice@mycompany.com", "/Acme-tech/"},
// Insider not on closed project.
{"insider not on closed", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("alice@mycompany.com"),
}, HasAnyFile: true}, "bob@mycompany.com", "/Acme-comm/"},
// Vendor at /Archive/Acme/.
{"vendor at own folder", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("acme-rep@acme.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Acme/"},
// Vendor blocked at sibling-vendor folder.
{"vendor blocked at sibling", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("beta-rep@beta.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Beta/"},
// The anti-pattern trap: same-level allow + deny *@company.com
// blocks the supposedly-allowed user too (deny is checked first
// within a level, so this is a documentation/rego parity check).
{"trap same-level allow+deny shadow", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allowDeny([]string{"alice@mycompany.com"}, []string{"*@mycompany.com"}),
}, HasAnyFile: true}, "alice@mycompany.com", "/Trap/"},
}
internal := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: tc.path, PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
// Run the Go evaluator.
goAllow, err := internal.Allow(ctx, input)
if err != nil {
t.Fatalf("internal: %v", err)
}
// Run the Rego evaluator on identical input.
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
rs, err := preparedQuery.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("rego eval: %v", err)
}
if len(rs) == 0 {
t.Fatalf("rego: no result")
}
regoAllow, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
t.Fatalf("rego: result is not bool: %v", rs[0].Expressions[0].Value)
}
if goAllow != regoAllow {
t.Errorf("PARITY BROKEN: internal=%v, rego=%v\n email=%q path=%q\n chain=%+v",
goAllow, regoAllow, tc.email, tc.path, tc.chain)
}
})
}
}
// canonicalInput serializes the AllowInput through JSON and back so that
// the Rego evaluator sees the exact same shape an external OPA would
// receive over the wire. Catches accidents where a struct-literal
// fixture would expose a Go-only field name (capitalized) that wouldn't
// reach a real OPA deployment.
func canonicalInput(in AllowInput) (map[string]interface{}, error) {
b, err := json.Marshal(in)
if err != nil {
return nil, err
}
var out map[string]interface{}
if err := json.NewDecoder(strings.NewReader(string(b))).Decode(&out); err != nil {
return nil, err
}
return out, nil
}

View file

@ -0,0 +1,322 @@
// Package policy is the access-decision boundary for zddc-server.
//
// All ACL checks in handlers go through Decider.Allow rather than
// calling zddc.AllowedWithChain directly. This lets a deployment
// route policy decisions to an external OPA-compatible server
// (for federal customers running their own audited Rego policies)
// without changing handler code.
//
// Two implementations:
//
// - InternalDecider — wraps zddc.AllowedWithChain. The default;
// no new dependencies, identical semantics to the legacy code
// path. This is what the docs in zddc/README.md describe.
//
// - HTTPDecider — POSTs to OPA's canonical /v1/data/<package>/allow
// endpoint over HTTP or a Unix-domain socket. Federal customers
// deploy real OPA alongside zddc-server, write their own Rego,
// and point ZDDC_OPA_URL at it.
//
// Configuration knob:
//
// ZDDC_OPA_URL= # internal (default)
// ZDDC_OPA_URL=internal # internal (explicit)
// ZDDC_OPA_URL=http://127.0.0.1:8181 # external HTTP
// ZDDC_OPA_URL=https://opa.example:8181 # external HTTPS
// ZDDC_OPA_URL=unix:///run/opa/opa.sock # external Unix socket
//
// Failure mode (external only): unreachable / non-2xx / malformed
// response → fail closed (deny), with a WARN log. Operators who
// prefer availability over correctness can set ZDDC_OPA_FAIL_OPEN=1
// to flip to fail-open with a WARN log instead.
package policy
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// AllowInput is the canonical input shape for Decider.Allow. It
// matches OPA's input convention: a JSON object passed as the
// "input" field of a /v1/data/<package>/allow query.
//
// External Rego policies can:
// - read input.user.email (string)
// - read input.path (string)
// - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally.
type AllowInput struct {
User struct {
Email string `json:"email"`
} `json:"user"`
Path string `json:"path"`
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
}
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
// We don't tag zddc.PolicyChain itself because it's tightly coupled
// to the parser; the duplication is one struct.
type SerializableChain struct {
Levels []zddc.ZddcFile `json:"levels"`
HasAnyFile bool `json:"has_any_file"`
}
func chainToSerializable(c zddc.PolicyChain) *SerializableChain {
return &SerializableChain{Levels: c.Levels, HasAnyFile: c.HasAnyFile}
}
// Decider is the access-decision interface every handler uses.
type Decider interface {
Allow(ctx context.Context, input AllowInput) (bool, error)
}
// Config selects and parameterizes the decider.
type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
}
// New constructs a Decider per cfg.URL semantics.
// - "" or "internal" → InternalDecider (no cache — the in-process
// evaluator is already cheaper than a cache lookup would be)
// - "http(s)://..." → HTTPDecider wrapped in a small per-decision
// cache (default 1s TTL — short enough that staleness is bounded
// to the same window as fsnotify-debounced index refresh, long
// enough to amortize bursty listings like .archive enumeration
// into one OPA round-trip per (email, decision-input))
// - "unix:///..." → same as http(s), over a Unix socket
//
// Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) {
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil
}
var inner Decider
var err error
switch {
case strings.HasPrefix(cfg.URL, "http://"), strings.HasPrefix(cfg.URL, "https://"):
inner, err = newHTTPDecider(cfg.URL, cfg.FailOpen, nil)
case strings.HasPrefix(cfg.URL, "unix://"):
path := strings.TrimPrefix(cfg.URL, "unix://")
dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", path)
},
}
inner, err = newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport)
default:
return nil, fmt.Errorf("unrecognized ZDDC_OPA_URL %q (want \"internal\", http(s)://..., or unix:///...)", cfg.URL)
}
if err != nil {
return nil, err
}
ttl := cfg.CacheTTL
if ttl == 0 {
ttl = time.Second
}
if ttl < 0 {
// Negative TTL = caching disabled (test seam).
return inner, nil
}
return &cachingDecider{inner: inner, ttl: ttl}, nil
}
// InternalDecider routes Allow through zddc.AllowedWithChain. No
// network, no Rego, no new dependencies — same Go evaluator the
// existing test suite covers.
type InternalDecider struct{}
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{}
if input.PolicyChain != nil {
chain.Levels = input.PolicyChain.Levels
chain.HasAnyFile = input.PolicyChain.HasAnyFile
}
return zddc.AllowedWithChain(chain, input.User.Email), nil
}
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
// endpoint. Spec:
// - request body {"input": <AllowInput>}
// - response body {"result": true|false}
// - 5-second per-request timeout
// - non-2xx, transport error, missing/malformed result → policy
// decision is "deny" unless FailOpen=true
//
// The path "/v1/data/zddc/access/allow" is the OPA convention; the
// "zddc.access" Rego package on an external server would expose
// `allow` for this endpoint.
type HTTPDecider struct {
endpoint string
client *http.Client
failOpen bool
}
func newHTTPDecider(endpoint string, failOpen bool, transport http.RoundTripper) (*HTTPDecider, error) {
if _, err := url.Parse(endpoint); err != nil {
return nil, fmt.Errorf("invalid OPA URL %q: %w", endpoint, err)
}
c := &http.Client{Timeout: 5 * time.Second}
if transport != nil {
c.Transport = transport
}
return &HTTPDecider{
endpoint: strings.TrimRight(endpoint, "/") + "/v1/data/zddc/access/allow",
client: c,
failOpen: failOpen,
}, nil
}
type opaResponse struct {
Result *bool `json:"result"`
}
func (d *HTTPDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
body, err := json.Marshal(struct {
Input AllowInput `json:"input"`
}{Input: input})
if err != nil {
return d.failResult(fmt.Errorf("marshal input: %w", err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.endpoint, bytes.NewReader(body))
if err != nil {
return d.failResult(fmt.Errorf("build request: %w", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := d.client.Do(req)
if err != nil {
return d.failResult(fmt.Errorf("opa request: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read up to 512 bytes of the error body for the log without
// blowing up on a verbose OPA error page.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return d.failResult(fmt.Errorf("opa returned %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))))
}
var parsed opaResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return d.failResult(fmt.Errorf("decode opa response: %w", err))
}
if parsed.Result == nil {
return d.failResult(errors.New("opa response missing 'result' field"))
}
return *parsed.Result, nil
}
// failResult logs the failure and returns the configured fail-mode
// decision. Logged at WARN so a healthy run is silent but a sick OPA
// is loud.
func (d *HTTPDecider) failResult(err error) (bool, error) {
if d.failOpen {
slog.Warn("policy decision failed; failing open (allow)", "endpoint", d.endpoint, "err", err)
return true, nil
}
slog.Warn("policy decision failed; failing closed (deny)", "endpoint", d.endpoint, "err", err)
return false, nil
}
// AllowFromChain is a convenience for callers that already have a
// PolicyChain in hand. Equivalent to constructing AllowInput manually
// from (chain, email, path) and calling d.Allow.
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)}
in.User.Email = email
return d.Allow(ctx, in)
}
// cachingDecider wraps another Decider with a small per-decision cache.
// Designed for the external-OPA hot path: a single .archive listing or
// directory enumeration can hit the same (email, dir-policy) tuple
// dozens of times in milliseconds, and a remote OPA round-trip per
// query would dominate latency. The 1s default TTL bounds staleness to
// the same window as the fsnotify watcher's debounce, so a `.zddc` edit
// is reflected in the next listing rather than carried over indefinitely.
//
// Key shape: SHA-256 of the canonical JSON-serialized AllowInput. This
// makes the cache safe across all input variations (different paths,
// different chains, different users) without us having to enumerate
// the dimensions.
type cachingDecider struct {
inner Decider
ttl time.Duration
mu sync.Mutex
entries map[string]cacheEntry
}
type cacheEntry struct {
expires time.Time
allow bool
}
func (d *cachingDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
key, err := cacheKey(input)
if err != nil {
// Couldn't key — fall through to inner without caching. Should
// never happen in practice; AllowInput marshals as plain JSON.
return d.inner.Allow(ctx, input)
}
now := time.Now()
d.mu.Lock()
if d.entries == nil {
d.entries = make(map[string]cacheEntry)
}
if e, ok := d.entries[key]; ok && now.Before(e.expires) {
d.mu.Unlock()
return e.allow, nil
}
d.mu.Unlock()
allow, err := d.inner.Allow(ctx, input)
if err != nil {
return allow, err
}
d.mu.Lock()
// Best-effort eviction of expired entries — keeps the map from
// growing unbounded under high cardinality. O(n) but capped to
// occasional sweeps; fine for this scale.
if len(d.entries) > 4096 {
for k, e := range d.entries {
if now.After(e.expires) {
delete(d.entries, k)
}
}
}
d.entries[key] = cacheEntry{expires: now.Add(d.ttl), allow: allow}
d.mu.Unlock()
return allow, nil
}
func cacheKey(input AllowInput) (string, error) {
b, err := json.Marshal(input)
if err != nil {
return "", err
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:]), nil
}

View file

@ -0,0 +1,403 @@
package policy
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestNew_ModeSelection: New() picks the right implementation per URL.
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
// by default; CacheTTL<0 disables the wrapper.
func TestNew_ModeSelection(t *testing.T) {
cases := []struct {
url string
ttl time.Duration
wantType string
wantErr bool
}{
{"", 0, "*policy.InternalDecider", false},
{"internal", 0, "*policy.InternalDecider", false},
{"INTERNAL", 0, "*policy.InternalDecider", false},
{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
{"ftp://nope", 0, "", true},
{"garbage", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) {
d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
if tc.wantErr {
if err == nil {
t.Fatal("New() = nil error, want error")
}
return
}
if err != nil {
t.Fatalf("New() unexpected error: %v", err)
}
got := describe(d)
if got != tc.wantType {
t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
}
})
}
}
func describe(v interface{}) string {
switch v.(type) {
case *InternalDecider:
return "*policy.InternalDecider"
case *HTTPDecider:
return "*policy.HTTPDecider"
case *cachingDecider:
return "*policy.cachingDecider"
default:
return "unknown"
}
}
// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
want bool
}{
{
"empty chain, no files → allow",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true,
},
{
"files exist but no rule matches → deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf allow wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf deny beats parent allow (bottom-up first match)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf has no rule for user, falls back to parent allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allow("bob@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf allows user that parent denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"multi-level: deepest match wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
}
d := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
if err != nil {
t.Fatalf("AllowFromChain: %v", err)
}
want := zddc.AllowedWithChain(tc.chain, tc.email)
if got != want {
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
}
if got != tc.want {
t.Errorf("decider = %v, want %v", got, tc.want)
}
})
}
}
// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
// OPA shape and acts on a 200 with {"result": true|false}.
func TestHTTPDecider_HappyPath(t *testing.T) {
var captured struct {
path string
contentType string
body []byte
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.path = r.URL.Path
captured.contentType = r.Header.Get("Content-Type")
captured.body, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
in.PolicyChain = &SerializableChain{HasAnyFile: true}
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("Allow = false, want true")
}
if captured.path != "/v1/data/zddc/access/allow" {
t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
}
if !strings.HasPrefix(captured.contentType, "application/json") {
t.Errorf("content-type = %q, want application/json", captured.contentType)
}
// Body should be {"input": {...}}
var wrap struct{ Input AllowInput }
if err := json.Unmarshal(captured.body, &wrap); err != nil {
t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
}
if wrap.Input.User.Email != "alice@example.com" {
t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
}
}
// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
// returns deny (false) by default, with no Go error returned to
// the caller (handlers don't have to special-case it).
func TestHTTPDecider_FailClosed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on 500, Allow = true, want false (fail-closed default)")
}
}
// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
// returns allow.
func TestHTTPDecider_FailOpen(t *testing.T) {
d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
}
}
// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
// the default 1s cache only round-trips once for a burst of identical
// queries. Verifies the listing-amortization benefit for external mode.
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
// 50 identical calls → exactly 1 round-trip thanks to the cache.
for i := 0; i < 50; i++ {
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow #%d: %v", i, err)
}
if !got {
t.Errorf("Allow #%d = false, want true", i)
}
}
if hits != 1 {
t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
}
}
// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
// path produces a separate cache entry; a different-decision answer is
// not masked by the cached one.
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var wrap struct {
Input AllowInput `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&wrap)
// Allow only "alice"; deny everyone else.
allow := wrap.Input.User.Email == "alice@example.com"
resp, _ := json.Marshal(map[string]bool{"result": allow})
_, _ = w.Write(resp)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
for _, tc := range []struct {
email string
want bool
}{
{"alice@example.com", true},
{"bob@example.com", false},
{"alice@example.com", true}, // cached
{"bob@example.com", false}, // cached
{"carol@example.com", false}, // new
} {
in := AllowInput{Path: "/p"}
in.User.Email = tc.email
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow(%s): %v", tc.email, err)
}
if got != tc.want {
t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
}
}
}
// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
func TestCachingDecider_TTLExpires(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
// Two calls so far; the second is a cache hit. hits==1.
if hits != 1 {
t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
}
// Wait past the TTL.
time.Sleep(20 * 1000000) // 20ms
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if hits != 2 {
t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
}
}
// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
// inner decider unwrapped, useful for tests that want predictable
// per-call HTTP traffic.
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: -1})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
for i := 0; i < 5; i++ {
_, _ = d.Allow(context.Background(), in)
}
if hits != 5 {
t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
}
}
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
// result field also fails closed.
func TestHTTPDecider_MalformedResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on missing result, Allow = true, want false (fail-closed)")
}
}

View file

@ -0,0 +1,26 @@
package policy
import _ "embed"
// ReferenceRego is the canonical Rego policy bundled with zddc-server.
// It mirrors the InternalDecider's semantics exactly — every release CI
// run validates parity via parity_test.go (which imports the OPA library
// as a test-only dependency, so the production binary stays OPA-free).
//
// Operators running an external OPA can use this as the starting point
// for their own policy bundle:
//
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
//
// Customizations typical for federal deployments:
//
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
// are absolute (NIST AC-6 least-privilege posture).
// - Add role-based access via additional input fields (input.user.roles
// populated by the upstream proxy from SAML/OIDC claims).
// - Add time-of-day or IP-range constraints.
// - Emit decision logs in a SIEM-friendly format via OPA's logging
// plugins.
//
//go:embed rego/access.rego
var ReferenceRego string

View file

@ -0,0 +1,119 @@
# Reference Rego policy that mirrors zddc-server's built-in `internal`
# decider exactly. Federal customers running their own OPA can use this
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
# parent-deny rule for NIST AC-6 compliance).
#
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
# truth for production. This file is validated against that evaluator on
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
# Both implementations must produce the same decision for every fixture.
#
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
# {
# "user": {"email": "alice@example.com"},
# "path": "/Project-A/sub/",
# "policy_chain": {
# "levels": [
# {"acl": {}, "admins": ["admin@example.com"]},
# {"acl": {"allow": ["*@example.com"]}}
# ],
# "has_any_file": true
# }
# }
#
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
# bottom-up (deepest first); first explicit match wins; within a single
# level, a deny pattern is checked before an allow pattern.
#
# Default-allow when has_any_file is false (no .zddc anywhere → public);
# default-deny when has_any_file is true and nothing matched (the safety
# net the file at <ZDDC_ROOT>/.zddc enables).
package zddc.access
import future.keywords.if
import future.keywords.in
default allow := false
# Allow when no .zddc files anywhere in the chain AND no rule matches.
allow if {
not input.policy_chain.has_any_file
count(matched_levels) == 0
}
# Allow when the deepest matching level grants.
allow if {
count(matched_levels) > 0
deepest := max(matched_levels)
level_grants(input.policy_chain.levels[deepest])
}
# Set of level indices where the email matches at least one allow or deny
# pattern. The deepest-index member is the level whose decision counts.
matched_levels := {i |
some i
level_matches(input.policy_chain.levels[i])
}
# A level "matches" if its email is in either its deny list or its allow
# list. Whether the level grants or denies is a separate question
# (level_grants below) — deny is checked before allow within a level.
level_matches(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
level_matches(level) if {
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
# A level grants iff (a) no deny pattern matches at this level AND (b) some
# allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked
# first; if no deny hit, an allow match returns true.
level_grants(level) if {
not level_denies(level)
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
level_denies(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
# email_matches: glob-match a pattern against an email, with the @-boundary
# rule from acl.go's MatchesPattern: * does not cross @. Four cases:
#
# 1. exact match (covers patterns with no wildcard)
# 2. bare "*" matches any non-empty email (special case because OPA's
# glob.match treats empty delimiters [] inconsistently for the
# lone-* pattern)
# 3. pattern has both * and @: standard glob with @ as a delimiter so
# `*@example.com` matches alice@example.com but `*example.com`
# does NOT match anything (* won't cross @)
# 4. pattern has * but no @: glob against the full email with no
# delimiter (so `alice*` matches alice@anything)
email_matches(pattern, email) if {
pattern == email
}
email_matches(pattern, email) if {
pattern == "*"
email != ""
}
email_matches(pattern, email) if {
contains(pattern, "*")
contains(pattern, "@")
glob.match(pattern, ["@"], email)
}
email_matches(pattern, email) if {
contains(pattern, "*")
not contains(pattern, "@")
pattern != "*"
glob.match(pattern, [], email)
}

View file

@ -7,9 +7,13 @@ import (
)
// ACLRules holds email allow/deny lists.
//
// JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct {
Allow []string `yaml:"allow"`
Deny []string `yaml:"deny"`
Allow []string `yaml:"allow" json:"allow,omitempty"`
Deny []string `yaml:"deny" json:"deny,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
@ -36,10 +40,10 @@ type ACLRules struct {
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
// and never re-validates — operators delete the file to force a refetch.
type ZddcFile struct {
ACL ACLRules `yaml:"acl"`
Admins []string `yaml:"admins"`
Title string `yaml:"title"`
Apps map[string]string `yaml:"apps,omitempty"`
ACL ACLRules `yaml:"acl" json:"acl"`
Admins []string `yaml:"admins" json:"admins,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
}
// ParseFile reads and parses a .zddc YAML file.