Compare commits

...

6 commits

Author SHA1 Message Date
570604ca80 docs(mdedit): fix stale 'Select Directory' reference in README
The button was renamed from 'Select Directory' to 'Add Local Directory'
in 22c142e (chore(headers): standardize across all 7 tools). The
template, in-tool welcome hint, and in-tool help text all updated;
mdedit/README.md was missed.

Investigated the underlying bug report ('Select Directory button isn't
showing'): the button itself is fine — present in template.html with
btn-primary class, never hidden, only visually demoted to btn--subtle
once a directory is loaded. The user's report was new-label confusion
from the rename. Fixing the README to match the rendered label closes
the discrepancy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:07:37 -05:00
d3a9ea7ad9 feat(server): federal-mode reference Rego (parent-deny-is-absolute)
Ship a second parity-tested Rego policy that flips the cascade's
leaf-allow-overrides-parent-deny rule for NIST AC-6 conformance.

Standard cascade (existing access.rego, mirrors internal Go evaluator):
  Bottom-up walk; first explicit match wins; deny-first within a level.
  A leaf-level allow CAN override an ancestor's deny. This is the
  cascade's intentional delegation property — a project-owner who
  re-allows a previously-denied collaborator works as expected.

Federal mode (new access_federal.rego):
  Any deny anywhere along the chain is absolute. An allow only matters
  if no level (any depth) has denied the same email. Required by
  NIST AC-6 default expectations: a central admin's deny at the root
  must be unbypassable by a tenant who controls a subtree's .zddc.

Operators run real OPA with this Rego and point ZDDC_OPA_URL at it;
the internal Go evaluator stays on the commercial cascade. The
toggle is "which policy does your OPA evaluate," not a knob inside
zddc-server.

Surfaced via --print-rego flag:

  zddc-server --print-rego               # standard (default)
  zddc-server --print-rego=standard      # same
  zddc-server --print-rego=federal       # AC-6 strict variant

Parity test (federal_parity_test.go) compiles both Regos and asserts:
  * They AGREE on every cascade scenario where no ancestor-deny
    intersects a leaf-allow (most cases).
  * They DISAGREE — by design — on the three scenarios where the
    AC-6 rule differs:
      - "leaf allows what parent denied" → standard allows, federal denies
      - "deep leaf re-allows after middle deny" → same
      - "glob deny at root + specific allow at leaf" → same

Cross-checks the divergence flag explicitly so any future change that
accidentally collapses the two policies fails the test.

Closes the AC-6 row of the federal-readiness gap analysis (now marked
"partially complete" in zddc/README.md — the full bullet would be a
built-in --policy-mode=federal toggle that also flips the in-process
Go evaluator).

Production binary unchanged at 13.1 MB (Rego files embedded as bytes;
OPA library remains test-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:05:44 -05:00
2607ca9b8a feat(server): /.profile/effective-policy cascade tracer (admin-only)
Eliminates the manual cascade-trace ritual when debugging "why can't
alice see /Project-X" reports. New endpoint returns the resolved
policy chain plus the active decider's verdict in JSON:

  GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@…

Response shape:

  {
    "path": "/Project-X/sub/",
    "email": "alice@…",
    "decision": true,
    "decider_kind": "*policy.InternalDecider",
    "chain": {
      "has_any_file": true,
      "levels": [
        {"index": 0, "zddc_path": "/.zddc", "exists": true,
         "acl": {...}, "admins": [...],
         "matches_email": false, "decision_at_level": "no_match"},
        {"index": 1, "zddc_path": "/Project-X/.zddc", "exists": true,
         "acl": {...}, "matches_email": true, "decision_at_level": "allow"}
      ]
    }
  }

Per-level email matching reuses the same MatchesPattern code the live
evaluator uses, so the trace can never disagree with the actual
verdict — and when ZDDC_OPA_URL points at an external OPA, the
decision goes through that OPA, making the endpoint a useful smoke
test for OPA wiring too.

Admin-only via the existing /.profile gate (404 to non-admins).
Required params; 400 if either is missing or path doesn't escape ROOT.

Test coverage:
  * TestServeProfileGateMatrix: anonymous → 404, non-admin → 404,
    admin without params → 400 (gate cleared, validator rejected)
  * TestServeProfileEffectivePolicy: full payload-shape assertion
    against a worked-example fixture (closed project where alice is
    allow-listed but bob is not)

Also fixes pre-existing doc drift: README's "Admin Debug Page"
section referenced /.admin/whoami|config|logs but the actual code
mounts /.profile/* (the rename predates this PR; the doc was stale).

Closes the "/.admin/effective-policy debug endpoint" item from the
federal-readiness future-work list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:01:24 -05:00
b20e98b6aa fix(apps): cache key now includes scheme + full host:port (no collisions)
The previous keyForURL stripped default ports (:443 for https, :80
for http) and omitted the scheme, so:

  http://example.com/x.html   ──┐
  https://example.com/x.html  ──┴──→ same cache entry (collision)

  https://example.com/x.html      ──┐
  https://example.com:443/x.html  ──┴──→ same cache entry

This was a defensible HTTP convention but a real correctness issue
on reverse-proxy stacks where http and https legitimately serve
different bytes for the same path, or where two upstreams share a
host but answer on different default ports.

New layout: <scheme>/<host>[:<port>]/<path>. Full origin tuple in
the key, no port stripping, scheme segregation. Examples:

  https/zddc.varasys.io/releases/archive_stable.html
  https/example.com:8443/x.html
  http/example.com/y.html      (distinct from https/example.com/y.html)

Operators retain the "ls _app/ to inspect what's cached" affordance
they relied on; they just see one extra directory layer (scheme
first, then host).

Tests:
  * Updated TestKeyForURL to assert the new layout for every
    previously-covered case
  * New TestKeyForURL_NoCollisions explicitly asserts that the
    dimensions previously collapsed (default-port↔bare, http↔https,
    different non-default ports) now produce distinct keys

Doc references to the cache layout under <ZDDC_ROOT>/_app/ updated
in zddc/README.md (3 mentions).

NOTE: existing _app/ caches under the old layout will be ignored
on first request after upgrade — entries will be re-fetched and
written to the new path. Operators can `rm -rf <ZDDC_ROOT>/_app`
during the upgrade window if they prefer not to have orphans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:57:28 -05:00
460d5fdada feat(server): TLS hardening per NIST SP 800-52 Rev. 2 + HSTS
The TLS configuration was using Go stdlib defaults — secure for typical
commercial use, but federal evaluators need an explicit cipher
allowlist they can map to a FIPS-validated implementation. Pin the
cipher and curve lists to NIST SP 800-52 Rev. 2 § 3.3 conformant
values:

  Ciphers (TLS 1.2):
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305

  Curves: X25519, P-256, P-384

  MinVersion: TLS 1.2 (already set; 1.3 used when negotiated)

TLS 1.3 cipher selection is not operator-controllable in Go stdlib
(the runtime picks from a fixed AEAD-only set); all of those
already meet the federal bar so no change needed there.

Also adds HSTSMiddleware emitting `Strict-Transport-Security:
max-age=31536000; includeSubDomains` when zddc-server is itself
terminating TLS (ZDDC_TLS_CERT != none). Behind an upstream proxy
terminating TLS the proxy is responsible for HSTS, so the middleware
only wraps the chain when useTLS=true.

Test coverage:
  * TLSConfig(none) returns nil + useTLS=false
  * TLSConfig(selfsigned) sets the exact NIST allowlist
  * Negative test asserting weak ciphers (CBC, RC4, 3DES, RSA-key-
    exchange) are NOT in the list — guardrail against regressions

Federal-readiness gap analysis updated: this control is now partially
complete. OCSP stapling and CT-log inclusion remain on the list for
full DoD STIG conformance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:55:52 -05:00
ca0364c197 docs: add SECURITY.md (vulnerability disclosure policy)
OpenSSF-template-shaped policy covering supported versions, reporting
channels (private email primary; GitHub Security Advisories on the
mirror as a structured-coordination secondary), response timeline, the
embargo+disclosure flow, CVE assignment process, and in-scope vs
out-of-scope examples.

Closes the NIST SI-5 (vulnerability disclosure) gap from the
federal-readiness gap analysis. Useful for every customer, federal
or not — formalizes the "where do I report this" question that
otherwise goes unanswered.

Out-of-scope examples explicitly enumerate the documented behaviors
that reporters sometimes mistake for vulnerabilities (email-header
forgery via direct bind-address connection; anonymous info disclosure
on /; apps: URL-fetch trust). Saves both reporter and maintainer time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:53:13 -05:00
14 changed files with 1081 additions and 67 deletions

171
SECURITY.md Normal file
View file

@ -0,0 +1,171 @@
# Security Policy
This document describes how to report vulnerabilities in ZDDC and what to expect
in response. Compatible with the OpenSSF [`SECURITY.md` template] and addresses
NIST 800-53 SI-5 (vulnerability disclosure) for federal evaluators.
[`SECURITY.md` template]: https://github.com/ossf/oss-vulnerability-guide
## Supported versions
Security fixes are issued for:
- **Latest stable release** of every artifact (six HTML tools + zddc-server) —
always supported.
- **Previous stable major-minor** (e.g. `v1.2.x` while `v1.3.x` is current) —
best-effort backports for issues with severity ≥ High (CVSS v3.1 ≥ 7.0).
- **Earlier versions** — out of scope. Reproduce the issue on a current
release before reporting.
zddc-server's release tags (`zddc-server-vX.Y.Z`) and the HTML tool tags
(`<tool>-vX.Y.Z`) all share the same X.Y.Z on lockstep cuts; "current stable"
means the highest X.Y.Z tagged across the seven artifacts.
## Reporting a vulnerability
**Preferred channel: private email.**
Send a description of the issue, reproduction steps, and (if known) a
suggested fix to:
> **caseywitt@proton.me**
Include in the email:
1. The artifact and version affected (e.g. `zddc-server-v0.0.16` or
`archive-v0.0.16`).
2. The shortest reproduction you can produce — a single curl command, a
`.zddc` fixture that triggers the issue, etc.
3. Your assessment of severity (CVSS v3.1 vector if you can; otherwise a
short description of impact).
4. Whether you'd like attribution in the eventual advisory or prefer to
stay anonymous.
PGP encryption is available on request — reply to the initial email asking
for the key; we'll send it from the same address.
**Secondary channel: GitHub Security Advisories.** This repository's
upstream is at <https://codeberg.org/VARASYS/ZDDC>; a mirror is published
on GitHub when one is needed for ecosystem compatibility. If you prefer
the GitHub workflow over email, use the "Report a vulnerability" link on
the GitHub mirror's Security tab. Codeberg does not yet have an
equivalent of GitHub Security Advisories, so for embargoed coordination
GitHub is the better path.
**Do not** open a public issue on either Codeberg or the GitHub mirror
for security reports. Do not post reproduction details to any public
chat, mailing list, or social channel until the embargo lifts.
## Response timeline
| Time | What to expect |
|---|---|
| Within 3 business days of report | Acknowledgement that we received it and are looking at it |
| Within 14 days | Initial assessment: confirmed / not-a-vulnerability / need-more-info, with severity rating |
| Within 90 days | Fix released, or an updated timeline if the issue is genuinely complex |
If you don't hear back within 3 business days, retry — the email may have
been mis-filed. Mention "ZDDC security" in the subject so it stands out.
## Embargo and disclosure
We coordinate disclosure via embargo when the issue is non-trivial:
1. **Embargo agreed.** Reporter and maintainer agree on a public-disclosure
date (default 90 days from confirmation; sooner for severity Critical
issues with active exploitation).
2. **Fix developed.** Patch + tests in a private branch; reproduction case
added to the regression suite.
3. **Pre-disclosure notification.** ~1 week before the embargo lifts, we
notify known downstream redistributors (Helm-chart consumers,
container-image bakers) so they can stage updates.
4. **Coordinated release.** On the embargo date: release the fixed version,
publish the advisory (with reporter attribution if desired), request a
CVE if applicable.
5. **Public reproduction details.** Public proof-of-concept code is held
until 30 days after the fix release, to give operators a window to
upgrade.
## CVE assignment
For issues meeting the [MITRE CVE rules][cve-rules] (severity ≥ Medium,
publicly redistributed code, identifiable distinct vulnerability), we
request CVE IDs via:
- **GitHub Security Advisories**, when the GitHub mirror is the
disclosure channel (GitHub is a CVE Numbering Authority and assigns
IDs automatically for advisories published through their flow).
- **MITRE direct request**, otherwise, with the original report
attached as evidence.
[cve-rules]: https://www.cve.org/Resources/General/Counting-Rules.pdf
The CVE ID, when assigned, appears in:
- The release-notes section of the fixed version's tag annotation.
- The published advisory.
- The relevant entry in `CHANGELOG.md` (when one exists; not every
release ships with one).
## In-scope vulnerabilities
Examples of issues we want to hear about:
- Authentication / authorization bypass — anything that lets a caller
reach a directory their `.zddc` chain or external OPA policy says
they shouldn't.
- Path traversal — directory listings, archive resolutions, or form
submissions that escape `ZDDC_ROOT`.
- Injection — XSS in tool HTML, command injection in shell scripts,
YAML deserialization issues in `.zddc` parsing.
- Cryptographic weaknesses — though the production binary uses Go
stdlib crypto and the transmittal-signing feature uses Web Crypto;
TLS hardening is configurable.
- Cross-tool data leaks — one tool's storage being accessible via
another tool's UI or URL.
- Rate-limiting / resource-exhaustion — if a single unauthenticated
request can pin CPU, exhaust file descriptors, or fill disk.
- Supply-chain — tampering with vendored libraries, the `apps:` URL
fetch path, or the cross-compile build.
## Out of scope
- **Email-header forgery via direct connection to the bind address.**
Documented behavior — `zddc-server` trusts whatever the upstream
proxy sets as `X-Auth-Request-Email`. Network isolation is the
operator's responsibility (see `zddc/README.md` § "Trust boundary").
Reports of "I bypassed auth by curl-ing 127.0.0.1:8080 directly"
are not vulnerabilities; the documented deployment model requires
the bind address to be unreachable except via the proxy.
- **Anonymous information disclosure on `/` and `/.profile`.**
Documented as intentional — the public landing page is a project
picker filtered by ACL. Operators who can't tolerate disclosure
gate `/` behind their proxy's auth-required path.
- **`apps:` URL-fetch trust.** Documented — fetched-once-and-cached,
no integrity verification. Treat `.zddc` write authority as full
UI-mounting authority. (SHA-256 pinning is on the federal-readiness
roadmap; see `zddc/README.md` § "Federal-readiness gap analysis".)
- **Theoretical issues without a working reproduction** — we'll engage
with proof-of-concept code; not with hypothetical chains.
- **Issues in third-party dependencies that we vendor** — report those
to the upstream project. We'll backport their fix in the next
release cut. (Vendored libs: jszip, docx-preview, xlsx in some
HTML tools; gopkg.in/yaml.v3, fsnotify, lumberjack, klauspost
compress in zddc-server.)
- **Social engineering / phishing / misconfigured customer
deployments** unless the misconfiguration is plausibly the default.
## Federal evaluators
Key references for ATO conversations:
- `zddc/README.md` § "Federal-readiness gap analysis" — every NIST
control we know is incomplete, with the planned remediation.
- `ARCHITECTURE.md` § "Server security model" — commercial-vs-federal
trust model side-by-side.
- `zddc/internal/policy/` — pluggable OPA-compatible policy decider
for federal customers running their own audited Rego policies.
We do not currently hold an ATO, FedRAMP authorization, or any other
formal accreditation. The system is designed to be ATO-able by
customers; the documentation aims to make that path obvious.

View file

@ -13,7 +13,7 @@ This tool follows the "record player with the record" philosophy - the applicati
## Quick Start
1. Open the editor in your browser
2. Click **Select Directory** to choose a folder with markdown files
2. Click **Add Local Directory** to choose a folder with markdown files
3. Navigate the file tree on the left
4. Click any `.md` file to edit it
5. Click **Save File** or **Save All** to save changes

View file

@ -344,7 +344,7 @@ naive intuition suggests.
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
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
the resolver order; SHA-256 pinning is on the federal-readiness list, not
currently implemented.)
@ -410,31 +410,45 @@ guarantee these for the model above to hold:
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,
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<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:
the fastest path is the built-in cascade tracer:
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
```
GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@mycompany.com
```
(Admin-only — 404 to non-admins. Same gate as `/.profile/whoami`,
`/.profile/config`, `/.profile/logs`.)
Returns JSON with the resolved policy chain (every level along the
walk from `ZDDC_ROOT` to the requested directory), the decision the
active decider produces, the per-level email-match breakdown
(`decision_at_level: "allow" | "deny" | "no_match"`), and which
decider is wired in (`*policy.InternalDecider`,
`*policy.cachingDecider`, etc.). With `ZDDC_OPA_URL` pointing at an
external OPA, the decision goes through that OPA — so this endpoint
also doubles as a smoke test for the OPA wiring.
Manual procedure (if the endpoint isn't reachable for some reason):
1. **Confirm the resolved email** — hit `/.profile/whoami` as the
user. 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.
`/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.
### Directory visibility
@ -552,10 +566,15 @@ 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.
- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now
sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2
AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305
variants), curve preferences (X25519, P-256, P-384), and emits HSTS
(`max-age=31536000; includeSubDomains`) when zddc-server itself
terminates TLS. *Still required for full DoD STIG conformance:*
OCSP stapling, certificate-transparency-log inclusion, and an
audit-grade documentation pack mapping the cipher list to FIPS
140-3 validated implementations.
- **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).
@ -566,10 +585,16 @@ have to redo the gap analysis from scratch.
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.
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
Leaf-allow-overrides-parent-deny is the cascade's intentional
delegation behavior in commercial mode and is preserved in the
internal Go evaluator. For federal deployments, `--print-rego=federal`
emits a parity-tested Rego policy where parent denies are absolute;
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
that switches the in-process Go evaluator's semantics without
requiring an OPA sidecar — currently federal-mode is reachable only
via the external-OPA path.
- **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
@ -695,20 +720,36 @@ 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:
The `--print-rego` flag emits the bundled reference Rego policies. Two
variants ship:
```sh
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
zddc-server --print-rego # standard cascade (commercial)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
```
The standard variant mirrors internal-mode semantics exactly — leaf-
level allows can override an ancestor's deny (the cascade's intentional
delegation property). The federal variant is the strict-least-privilege
posture: any deny anywhere in the chain is absolute, no leaf-level
override possible. Federal customers running their own OPA can drop
the federal Rego in unchanged, or use either as a starting point for
further customization.
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.
imports the OPA Go module **as a test-only dependency**, evaluates both
bundled Regos against fixture sets and asserts:
- The standard Rego matches the internal Go evaluator on every documented
cascade scenario (`TestRegoParity_AllInternalCases`).
- The federal Rego agrees with the standard policy on every case where
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
This way both policies are guaranteed to behave as documented.
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
@ -753,9 +794,6 @@ the *interpretation* of those files differs per tenant.
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.
@ -773,22 +811,30 @@ gaps that warrant code, in addition to the federal-readiness items above:
## Admin Debug Page
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
`zddc-server` exposes a built-in debug page at `/.profile/` for operators who can
push code/images but cannot `kubectl exec` into the running container. It surfaces:
- **`/.admin/whoami`** — every header on the current request, the configured email
- **`/.profile/whoami`** — every header on the current request, the configured email
header name, the value observed at that name, and the resolved email. This is the
first thing to look at when access logs show `email=anonymous` — it tells you
exactly which (if any) header the upstream proxy is sending.
- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to
- **`/.profile/config`** — the resolved `Config` (env vars). Equivalent to
`kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides.
- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer.
- **`/.profile/logs`** — recent log entries (last 500) from an in-memory ring buffer.
Optional `?level=info|warn|error|debug` and `?since=<RFC3339>` query params.
At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under
`msg=request headers` — useful for diagnosing proxy / SSO header passthrough
(e.g. confirming which header carries the email). Note: that dump includes
auth tokens and cookies; only enable debug in trusted environments.
- **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side.
- **`/.profile/effective-policy?path=...&email=...`** — cascade tracer.
Returns the resolved policy chain (every level along the walk from
`ZDDC_ROOT` to the requested path), the active decider's allow/deny
verdict, the per-level email-match breakdown, and the decider kind
(`*policy.InternalDecider` / `*policy.cachingDecider`). When
`ZDDC_OPA_URL` points at an external OPA, the decision goes through
that OPA — also a useful smoke test for OPA wiring. See "Debugging
permissions" above.
- **`/.profile/`** — HTML dashboard that fetches the JSON endpoints client-side.
### Authorization
@ -888,7 +934,7 @@ For any path, the resolution order is:
3. **Embedded** — the build-time HTML compiled into the binary.
URL sources are fetched once on first request and cached forever in
`<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh and no
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
hash verification — to pull a new build, delete the cache file. Concurrent
misses for the same URL share one outbound fetch (singleflight). Direct
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served

View file

@ -32,13 +32,21 @@ import (
var version = "dev"
func main() {
// --print-rego: dump the bundled reference Rego policy and exit.
// --print-rego: dump a 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.
// a parity-tested baseline as a starting point for customization.
//
// --print-rego → standard cascade (commercial default)
// --print-rego=standard → same
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
for _, a := range os.Args[1:] {
if a == "--print-rego" {
switch a {
case "--print-rego", "--print-rego=standard":
fmt.Print(policy.ReferenceRego)
return
case "--print-rego=federal":
fmt.Print(policy.FederalRego)
return
}
}
@ -146,9 +154,20 @@ func main() {
"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) {
// Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r)
})))))
})
inner = handler.CORSMiddleware(cfg, inner)
// HSTS only when zddc-server itself is the TLS-terminating endpoint.
// Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the
// proxy is responsible for HSTS — adding it here would conflict.
if useTLS {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, inner)
mux.Handle("/", inner)
gzWrapper, err := newGzipWrapper()
if err != nil {

View file

@ -37,7 +37,27 @@ func NewCache(root string) (*Cache, error) {
func (c *Cache) Root() string { return c.root }
// keyForURL converts a URL into a relative filesystem path under the
// cache root, e.g. "zddc.varasys.io/releases/archive_stable.html".
// cache root.
//
// Layout: <scheme>/<host>[:<port>]/<path>. The full origin tuple is in
// the key so two URLs that resolve different content cannot collide:
//
// https://example.com/x.html → https/example.com/x.html
// http://example.com/x.html → http/example.com/x.html
// https://example.com:8443/x.html → https/example.com:8443/x.html
//
// No port stripping. The previous behavior — collapsing :443 onto bare
// host for https (and :80 for http) — was a defensible HTTP convention
// but conflated "the operator wrote a URL with the default port" with
// "the operator wrote a bare-host URL". With explicit port preserved,
// every URL maps to exactly one filesystem path; operators can still
// `ls _app/https/example.com/` to inspect what's cached. Scheme
// segregation prevents an http:// hit from masquerading as an https://
// hit when both are deliberately distinct (rare, but real on
// reverse-proxied stacks where http and https serve different bytes).
//
// Host is lowercased so the canonical-host normalization survives
// case-insensitive DNS. Port is preserved verbatim.
func keyForURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
@ -52,13 +72,15 @@ func keyForURL(rawURL string) (string, error) {
if u.RawQuery != "" {
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
}
host := strings.ToLower(u.Host)
// Lowercase the host part but preserve the port verbatim. Without
// this we'd lowercase a numeric port unnecessarily, which is fine
// but pointless; with this the ASCII-cased host normalization
// works the same for both default and explicit-port URLs.
host := u.Host
if i := strings.Index(host, ":"); i >= 0 {
port := host[i+1:]
hostOnly := host[:i]
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
host = hostOnly
}
host = strings.ToLower(host[:i]) + host[i:]
} else {
host = strings.ToLower(host)
}
p := u.Path
for strings.Contains(p, "//") {
@ -72,7 +94,7 @@ func keyForURL(rawURL string) (string, error) {
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("URL path contains '..'")
}
return host + cleaned, nil
return u.Scheme + "/" + host + cleaned, nil
}
func (c *Cache) pathFor(rawURL string) (string, error) {

View file

@ -11,14 +11,26 @@ func TestKeyForURL(t *testing.T) {
cases := []struct {
raw, want string
}{
{"https://zddc.varasys.io/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"http://example.com:80/foo.html", "example.com/foo.html"},
{"https://example.com:443/foo.html", "example.com/foo.html"},
{"https://example.com:8443/foo.html", "example.com:8443/foo.html"},
{"https://example.com/", "example.com/index.html"},
{"https://example.com", "example.com/index.html"},
{"https://example.com//foo//bar.html", "example.com/foo/bar.html"},
// Default ports are PRESERVED — no port-stripping (the previous
// behavior conflated "operator wrote :443" with "operator wrote
// bare host"; with the full origin in the key, every URL maps
// to exactly one path).
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
{"http://example.com/foo.html", "http/example.com/foo.html"},
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
{"https://example.com/foo.html", "https/example.com/foo.html"},
{"https://example.com:443/foo.html", "https/example.com:443/foo.html"},
{"https://example.com:8443/foo.html", "https/example.com:8443/foo.html"},
// Scheme segregation: same host+path under http and https map
// to different cache entries (defensive against reverse-proxy
// stacks that legitimately serve different bytes per scheme).
{"http://example.com/x.html", "http/example.com/x.html"},
{"https://example.com/x.html", "https/example.com/x.html"},
// Path normalization preserved.
{"https://example.com/", "https/example.com/index.html"},
{"https://example.com", "https/example.com/index.html"},
{"https://example.com//foo//bar.html", "https/example.com/foo/bar.html"},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
@ -33,6 +45,38 @@ func TestKeyForURL(t *testing.T) {
}
}
// TestKeyForURL_NoCollisions: explicit assertion that the dimensions
// previously collapsed (default-port ↔ bare-host, http ↔ https) are
// now distinct. Any future change that re-introduces collapsing will
// fail this test.
func TestKeyForURL_NoCollisions(t *testing.T) {
pairs := [][2]string{
// Different scheme, same host+path
{"http://example.com/x.html", "https://example.com/x.html"},
// https default port preserved (not collapsed onto bare host)
{"https://example.com/x.html", "https://example.com:443/x.html"},
// http default port preserved
{"http://example.com/x.html", "http://example.com:80/x.html"},
// Different non-default ports
{"https://example.com:8443/x.html", "https://example.com:9443/x.html"},
}
for _, p := range pairs {
t.Run(p[0]+" vs "+p[1], func(t *testing.T) {
a, err := keyForURL(p[0])
if err != nil {
t.Fatalf("keyForURL(%q): %v", p[0], err)
}
b, err := keyForURL(p[1])
if err != nil {
t.Fatalf("keyForURL(%q): %v", p[1], err)
}
if a == b {
t.Errorf("collision: %q and %q both → %q", p[0], p[1], a)
}
})
}
}
func TestKeyForURL_Errors(t *testing.T) {
cases := []string{
"",

View file

@ -91,6 +91,26 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
return n, err
}
// HSTSMiddleware sets the Strict-Transport-Security response header,
// instructing browsers to refuse plain-HTTP connections to this host
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
// zddc-server is itself terminating TLS — when an upstream proxy
// terminates, that proxy should set HSTS instead.
//
// includeSubDomains is set; preload is not (preload requires
// pre-submitting the domain to the browser-vendor list — out of
// scope for this server, and operators who want it can override
// upstream).
//
// max-age = 31536000 = 365 days.
func HSTSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// AccessLogMiddleware logs a structured line per HTTP request after the
// response is written.
//

View file

@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"sort"
@ -63,6 +64,12 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
return
}
serveProfileLogs(ring, w, r)
case "/effective-policy":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileEffectivePolicy(cfg, w, r)
default:
http.NotFound(w, r)
}
@ -250,3 +257,180 @@ func levelRank(s string) int {
return 1 // unknown → info
}
}
// serveProfileEffectivePolicy is the cascade-tracer endpoint:
// /.profile/effective-policy?path=<URL-path>&email=<email>
// returns the resolved policy chain plus the allow/deny decision the
// active decider produces, in JSON. Eliminates the need for operators
// to manual-trace .zddc files when debugging "why can't alice see
// /Project-X?" reports.
//
// Both query params are required. The endpoint is admin-only (404 to
// non-admins via the dispatch gate).
//
// Response shape (each chain level is a directory along the walk
// from ZDDC_ROOT down to the requested path):
//
// {
// "path": "/Project-X/sub/",
// "email": "alice@mycompany.com",
// "decision": true,
// "decider_kind": "*policy.InternalDecider",
// "chain": {
// "has_any_file": true,
// "levels": [
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
// {"path": "/Project-X/", "exists": false},
// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
// ]
// }
// }
//
// Note: this evaluates the same input the production hot path would
// build for a request from <email> to <path>; if zddc-server is
// configured for external OPA, the decision goes through that OPA
// (so this endpoint is also a useful smoke test for the OPA wiring).
func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
probePath := q.Get("path")
probeEmail := q.Get("email")
if probePath == "" || probeEmail == "" {
http.Error(w, "both ?path= and ?email= are required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(probePath, "/") {
http.Error(w, "path must start with /", http.StatusBadRequest)
return
}
// Resolve the URL path to a filesystem directory the same way the
// dispatch hot path does.
rel := strings.TrimPrefix(probePath, "/")
rel = strings.TrimSuffix(rel, "/")
absDir, ok := safeJoin(cfg.Root, rel)
if !ok {
http.Error(w, "path escapes ZDDC_ROOT", http.StatusBadRequest)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
http.Error(w, "policy chain error: "+err.Error(), http.StatusInternalServerError)
return
}
// Evaluate the decision through whatever decider is wired into the
// request — internal in commercial deployments, an external OPA in
// federal ones. The returned bool is "allow".
ctx := r.Context()
decider := DeciderFromContext(r)
allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath)
type levelView struct {
Index int `json:"index"`
ZddcPath string `json:"zddc_path"`
Exists bool `json:"exists"`
Acl *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
AnyMatch bool `json:"matches_email"`
Decision string `json:"decision_at_level"`
}
// Build the per-level breakdown by walking the chain levels in
// the same order the cascade does (root → leaf in the data, but
// the live evaluator walks bottom-up). For each level we report
// whether the file actually existed (HasAnyFile is global; we
// don't have per-level existence, but ZddcFile.Admins/ACL being
// non-empty is a reasonable proxy).
out := struct {
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
Levels []levelView `json:"levels"`
} `json:"chain"`
}{
Path: probePath,
Email: probeEmail,
Decision: allow,
DeciderKind: deciderKind(decider),
}
out.Chain.HasAnyFile = chain.HasAnyFile
// Reconstruct level paths from cfg.Root. This mirrors how
// zddc.EffectivePolicy builds the chain (see cascade.go).
levelPaths := []string{cfg.Root}
if rel != "" {
current := cfg.Root
for _, seg := range strings.Split(rel, "/") {
if seg == "" {
continue
}
current = current + "/" + seg
levelPaths = append(levelPaths, current)
}
}
for i, lvl := range chain.Levels {
var lp string
if i < len(levelPaths) {
// Map filesystem path back to a URL-style path under
// cfg.Root for legibility in the response.
fsPath := levelPaths[i]
urlPath := strings.TrimPrefix(fsPath, cfg.Root)
if urlPath == "" {
urlPath = "/"
}
lp = urlPath + "/.zddc"
}
entry := levelView{
Index: i,
ZddcPath: lp,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0,
}
if entry.Exists {
entry.Acl = &lvl.ACL
entry.Admins = lvl.Admins
}
// Per-level email match: would this level's deny or allow
// patterns hit the email if checked? Reuses the same
// MatchesPattern code the live evaluator does.
anyMatch := false
decisionAtLevel := "no_match"
for _, p := range lvl.ACL.Deny {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "deny"
break
}
}
if !anyMatch {
for _, p := range lvl.ACL.Allow {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "allow"
break
}
}
}
entry.AnyMatch = anyMatch
entry.Decision = decisionAtLevel
out.Chain.Levels = append(out.Chain.Levels, entry)
}
writeJSON(w, out)
}
// deciderKind returns a short string label for the active decider.
// Mirrors the helper used in policy package tests; duplicated here
// to avoid a cross-package import that would only exist for one
// debug-endpoint string.
func deciderKind(d policy.Decider) string {
if d == nil {
return "nil"
}
t := fmt.Sprintf("%T", d)
return t
}

View file

@ -84,6 +84,12 @@ func TestServeProfileGateMatrix(t *testing.T) {
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
{"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK},
{"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK},
// effective-policy is admin-only too. With no params an admin
// gets 400 (bad request), confirming the gate cleared. Same
// 404 for non-admins as the other admin-only routes.
{"anonymous /.profile/effective-policy", "/.profile/effective-policy", "", http.StatusNotFound},
{"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound},
{"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest},
// Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
@ -469,6 +475,86 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
}
}
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
// (path, email) tuple and gets back the resolved chain plus the decision.
// The fixture mirrors the worked-example layout from zddc/README.md (a
// closed project where alice is allow-listed but bob is not, even though
// /Archive/ would let *@mycompany.com in).
func TestServeProfileEffectivePolicy(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
if err := os.MkdirAll(filepath.Join(cfg.Root, "Closed-Project"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
[]byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil {
t.Fatalf("write child .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
// Trace alice (allowed at the leaf).
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
Levels []struct {
Index int `json:"index"`
Exists bool `json:"exists"`
MatchesEmail bool `json:"matches_email"`
DecisionAtLevel string `json:"decision_at_level"`
} `json:"levels"`
} `json:"chain"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Path != "/Closed-Project/" || resp.Email != "alice@mycompany.com" {
t.Errorf("path/email round-trip mismatch: %+v", resp)
}
if !resp.Decision {
t.Errorf("decision = false, want true (alice is allow-listed at /Closed-Project/)")
}
if !resp.Chain.HasAnyFile {
t.Error("HasAnyFile = false, want true (.zddc files exist)")
}
if len(resp.Chain.Levels) != 2 {
t.Fatalf("levels count = %d, want 2 (root + Closed-Project/)", len(resp.Chain.Levels))
}
// Leaf level should have matched alice with allow.
leaf := resp.Chain.Levels[1]
if !leaf.MatchesEmail || leaf.DecisionAtLevel != "allow" {
t.Errorf("leaf decision = %q (matches=%v), want allow (matches=true)", leaf.DecisionAtLevel, leaf.MatchesEmail)
}
// Trace bob (not allow-listed; root has no broad allow either).
rec2 := httptest.NewRecorder()
r2 := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, rec2, r2)
if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
}
var resp2 struct {
Decision bool `json:"decision"`
}
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp2.Decision {
t.Error("decision = true for bob, want false (no .zddc match anywhere; HasAnyFile=true → default-deny)")
}
}
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.

View file

@ -0,0 +1,179 @@
package policy
import (
"context"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestFederalRego_DivergencesFromStandard validates the federal-mode
// variant by asserting both that:
//
// (a) most cascade scenarios produce the same verdict as standard
// (the federal rule reduces to standard whenever no parent deny
// intersects a leaf allow), AND
//
// (b) the specific scenarios where the rules differ (a leaf-level
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
// standard says allow (leaf wins); federal says deny (ancestor
// deny is absolute — NIST AC-6 default).
//
// Like the standard parity test, this imports the OPA library as a
// test-only dependency. The federal Rego is a deployable artifact
// (operators dump it via --print-rego=federal); the parity guard
// here proves the artifact behaves as documented.
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
ctx := context.Background()
standard, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile standard rego: %v", err)
}
federal, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile federal rego: %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}} }
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
wantStandard bool
wantFederal bool
divergesByDesign bool // true if standard and federal must disagree here
}{
// ── Cases where the two policies must AGREE ────────────────
{
"empty chain, no files",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true, true, false,
},
{
"files exist, no rule matches → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"leaf allow with no ancestor deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
{
"only deny anywhere → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"glob allow, no deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
// ── The signature divergence: leaf allow overlaying ancestor deny ──
{
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true, // standard: leaf wins
false, // federal: parent deny is absolute
true,
},
{
"deep leaf re-allows after middle deny → standard allows, federal denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
{
"glob deny at root, specific allow at leaf → both differ",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("*@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("standard eval: %v", err)
}
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("federal eval: %v", err)
}
if len(std) == 0 || len(fed) == 0 {
t.Fatal("rego returned empty result set")
}
stdAllow := std[0].Expressions[0].Value.(bool)
fedAllow := fed[0].Expressions[0].Value.(bool)
if stdAllow != tc.wantStandard {
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
}
if fedAllow != tc.wantFederal {
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
}
// Cross-check the divergence flag itself: if we said the cases
// must disagree, they must; if we said they agree, they must.
diverges := stdAllow != fedAllow
if diverges != tc.divergesByDesign {
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
diverges, tc.divergesByDesign, stdAllow, fedAllow)
}
})
}
}
// TestFederalRego_RegoCompiles is a sanity check that the embedded
// federal Rego file parses without error in OPA, separate from the
// behavior tests. Catches accidental syntax breakage in
// access_federal.rego before running the (slower) parity matrix.
func TestFederalRego_RegoCompiles(t *testing.T) {
_, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(context.Background())
if err != nil {
t.Fatalf("federal rego does not compile: %v", err)
}
}

View file

@ -15,7 +15,9 @@ import _ "embed"
// Customizations typical for federal deployments:
//
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
// are absolute (NIST AC-6 least-privilege posture).
// are absolute (NIST AC-6 least-privilege posture). For this specific
// case zddc-server ships a parity-tested federal-mode variant; see
// FederalRego and `--print-rego=federal`.
// - 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.
@ -24,3 +26,21 @@ import _ "embed"
//
//go:embed rego/access.rego
var ReferenceRego string
// FederalRego is the strict-least-privilege variant of ReferenceRego
// where parent denies are absolute (NIST AC-6). Drop-in for federal
// customers who need the AC-6 posture without writing Rego from
// scratch:
//
// zddc-server --print-rego=federal > /etc/opa/policies/zddc-access.rego
//
// The internal Go evaluator does NOT implement these semantics — it
// stays on the commercial cascade. Federal-mode is reachable only by
// running OPA with this policy and pointing ZDDC_OPA_URL at it. See
// zddc/internal/policy/rego/access_federal.rego for the policy itself
// and federal_parity_test.go for the divergence-test fixtures (cases
// where federal-mode and commercial-mode disagree, asserting each gives
// the expected verdict).
//
//go:embed rego/access_federal.rego
var FederalRego string

View file

@ -0,0 +1,95 @@
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
#
# This is a strict-least-privilege variant of access.rego. The two policies
# differ in exactly one rule, but the semantic difference is meaningful for
# federal evaluators:
#
# access.rego (commercial, default):
# "Bottom-up walk; first explicit match wins; deny-first within a level.
# A leaf-level allow CAN override an ancestor's deny."
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
#
# access_federal.rego (federal):
# "Any deny anywhere along the chain is absolute. An allow only matters
# if no ancestor (or sibling level) has denied the same email. Leaf-
# level allows do NOT override ancestor denies."
# Required by NIST AC-6 (Least Privilege) default expectations: a
# central admin's deny at the root must be unbypassable by a tenant
# who controls a subtree's .zddc.
#
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
# acl.go) implements only the commercial cascade — it's the rule the
# default deployment exercises. Federal customers running their own OPA
# with this file get the strict variant without any zddc-server code
# change. They can also write a hybrid policy (e.g. "deny is absolute
# only for emails matching some pattern; cascade rules for everyone
# else") since once they're hosting their own OPA, the constraint is
# whatever they write.
#
# Input shape: identical to access.rego — see that file's docstring.
package zddc.access_federal
import future.keywords.if
import future.keywords.in
default allow := false
# Allow when no .zddc files exist anywhere AND no rule matches.
# Same default-allow case as commercial; preserves the empty-tree
# behaviour. (zddc-server's --insecure check at startup makes this
# unreachable in any non-deliberately-public deployment.)
allow if {
not input.policy_chain.has_any_file
not any_deny_match
not any_allow_match
}
# Allow when files exist, no level (any depth) denies, and at least
# one level allows. The "any level" check is what makes parent denies
# absolute — there is no "deepest match wins" rule here.
allow if {
input.policy_chain.has_any_file
not any_deny_match
any_allow_match
}
# Any deny pattern at ANY level matches the email.
any_deny_match if {
some level in input.policy_chain.levels
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
# Any allow pattern at ANY level matches the email.
any_allow_match if {
some level in input.policy_chain.levels
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
# email_matches: identical to access.rego — see that file for the
# rationale on the four cases. Duplicated rather than imported so this
# file is self-contained for operators who copy it as a starting point.
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

@ -38,6 +38,32 @@ func TLSConfig(cfg config.Config) (*tls.Config, bool, error) {
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
// NIST SP 800-52 Rev. 2 conformant cipher allowlist for TLS 1.2.
// (TLS 1.3 ciphers are not operator-selectable in Go's stdlib —
// the runtime picks from a fixed set of AEAD suites; that's fine
// because all of them meet the federal bar.) Order matters when
// preferServerCipherSuites was respected by clients; modern Go
// uses the runtime's own preference, but the explicit list still
// drops every weak suite a client might offer.
// AES-128-GCM is listed before AES-256-GCM because hardware
// AES-NI makes the 128-bit suite measurably faster with no
// security-margin compromise (NIST allows both).
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
// NIST SP 800-52 Rev. 2 § 3.3.2: X25519, P-256, P-384.
// X25519 first — fastest modern curve, no known weaknesses;
// the NIST P-curves follow for clients that don't support it.
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
}, true, nil
}

View file

@ -0,0 +1,102 @@
package tlsutil
import (
"crypto/tls"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TestTLSConfig_NoneMode: TLSMode=="none" returns no config and useTLS=false.
func TestTLSConfig_NoneMode(t *testing.T) {
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "none"})
if err != nil {
t.Fatalf("TLSConfig(none): %v", err)
}
if useTLS {
t.Errorf("useTLS = true, want false for TLSMode=none")
}
if tlsCfg != nil {
t.Errorf("tlsCfg = %+v, want nil for TLSMode=none", tlsCfg)
}
}
// TestTLSConfig_SelfSignedHardenedDefaults: the self-signed path returns a
// config that conforms to NIST SP 800-52 Rev. 2 — TLS 1.2 minimum, the
// AEAD-only cipher allowlist, and the X25519/P-256/P-384 curve list.
func TestTLSConfig_SelfSignedHardenedDefaults(t *testing.T) {
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
if err != nil {
t.Fatalf("TLSConfig(selfsigned): %v", err)
}
if !useTLS {
t.Fatal("useTLS = false, want true")
}
if tlsCfg == nil {
t.Fatal("tlsCfg = nil")
}
if tlsCfg.MinVersion != tls.VersionTLS12 {
t.Errorf("MinVersion = %#x, want TLS 1.2 (%#x)", tlsCfg.MinVersion, tls.VersionTLS12)
}
wantCiphers := map[uint16]bool{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: true,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: true,
}
if len(tlsCfg.CipherSuites) != len(wantCiphers) {
t.Errorf("CipherSuites count = %d, want %d", len(tlsCfg.CipherSuites), len(wantCiphers))
}
for _, c := range tlsCfg.CipherSuites {
if !wantCiphers[c] {
t.Errorf("CipherSuites contains unexpected suite %#x; allowlist is the NIST SP 800-52 Rev. 2 set", c)
}
}
wantCurves := map[tls.CurveID]bool{
tls.X25519: true,
tls.CurveP256: true,
tls.CurveP384: true,
}
if len(tlsCfg.CurvePreferences) != len(wantCurves) {
t.Errorf("CurvePreferences count = %d, want %d", len(tlsCfg.CurvePreferences), len(wantCurves))
}
for _, c := range tlsCfg.CurvePreferences {
if !wantCurves[c] {
t.Errorf("CurvePreferences contains unexpected curve %v", c)
}
}
if len(tlsCfg.Certificates) != 1 {
t.Errorf("Certificates count = %d, want 1", len(tlsCfg.Certificates))
}
}
// TestTLSConfig_NoWeakCiphers: the allowlist must not include any of the
// federally-deprecated suites — CBC-mode, RC4, 3DES, SHA-1, NULL, EXPORT.
// This is a guardrail against accidental regressions if the list is edited.
func TestTLSConfig_NoWeakCiphers(t *testing.T) {
tlsCfg, _, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
if err != nil {
t.Fatalf("TLSConfig: %v", err)
}
weak := map[uint16]string{
tls.TLS_RSA_WITH_AES_128_CBC_SHA: "AES-128-CBC-SHA (CBC mode)",
tls.TLS_RSA_WITH_AES_256_CBC_SHA: "AES-256-CBC-SHA (CBC mode)",
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "RSA-AES-128-GCM (no forward secrecy)",
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "RSA-AES-256-GCM (no forward secrecy)",
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "ECDHE-RSA-AES-128-CBC-SHA (CBC mode)",
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "ECDHE-RSA-AES-256-CBC-SHA (CBC mode)",
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "3DES",
tls.TLS_RSA_WITH_RC4_128_SHA: "RC4",
}
for _, c := range tlsCfg.CipherSuites {
if name, bad := weak[c]; bad {
t.Errorf("CipherSuites includes federally-deprecated suite: %s (%#x)", name, c)
}
}
}