Compare commits
No commits in common. "570604ca808c8e72cd34a76b911742b224ec2083" and "5c33c8a8215bc5b3db9910dc602bb2852bfe675f" have entirely different histories.
570604ca80
...
5c33c8a821
14 changed files with 67 additions and 1081 deletions
171
SECURITY.md
171
SECURITY.md
|
|
@ -1,171 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -13,7 +13,7 @@ This tool follows the "record player with the record" philosophy - the applicati
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Open the editor in your browser
|
1. Open the editor in your browser
|
||||||
2. Click **Add Local Directory** to choose a folder with markdown files
|
2. Click **Select Directory** to choose a folder with markdown files
|
||||||
3. Navigate the file tree on the left
|
3. Navigate the file tree on the left
|
||||||
4. Click any `.md` file to edit it
|
4. Click any `.md` file to edit it
|
||||||
5. Click **Save File** or **Save All** to save changes
|
5. Click **Save File** or **Save All** to save changes
|
||||||
|
|
|
||||||
134
zddc/README.md
134
zddc/README.md
|
|
@ -344,7 +344,7 @@ naive intuition suggests.
|
||||||
and serve arbitrary HTML to every viewer below that level. Subtree write
|
and serve arbitrary HTML to every viewer below that level. Subtree write
|
||||||
authority on `.zddc` should be treated as full UI-mounting authority. The
|
authority on `.zddc` should be treated as full UI-mounting authority. The
|
||||||
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
|
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
|
||||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
|
`<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
|
the resolver order; SHA-256 pinning is on the federal-readiness list, not
|
||||||
currently implemented.)
|
currently implemented.)
|
||||||
|
|
||||||
|
|
@ -410,45 +410,31 @@ guarantee these for the model above to hold:
|
||||||
SIEM) via a sidecar; do not treat the local rotation as the system of
|
SIEM) via a sidecar; do not treat the local rotation as the system of
|
||||||
record.
|
record.
|
||||||
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
|
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
|
||||||
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
|
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
|
no signature. Use only URLs you control, treat the apps cache as a trust
|
||||||
boundary, and audit who has `.zddc` write authority where.
|
boundary, and audit who has `.zddc` write authority where.
|
||||||
|
|
||||||
### Debugging permissions
|
### Debugging permissions
|
||||||
|
|
||||||
When a user reports "I can't see /Project-X/" and you need to figure out why,
|
When a user reports "I can't see /Project-X/" and you need to figure out why,
|
||||||
the fastest path is the built-in cascade tracer:
|
manual cascade-tracing is the current path:
|
||||||
|
|
||||||
```
|
1. **Confirm the resolved email** — hit `/.admin/whoami` as the user (you'll
|
||||||
GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@mycompany.com
|
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.
|
||||||
(Admin-only — 404 to non-admins. Same gate as `/.profile/whoami`,
|
2. **List the chain.** From `<ZDDC_ROOT>` down to the requested directory,
|
||||||
`/.profile/config`, `/.profile/logs`.)
|
inspect each `.zddc` (most directories have none). For
|
||||||
|
|
||||||
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/sub/`, that's `/.zddc`, `/Project-X/.zddc`,
|
||||||
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever
|
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists.
|
||||||
exists.
|
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny
|
||||||
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`:
|
patterns first (any match → blocked), then allow (any match → allowed).
|
||||||
deny patterns first (any match → blocked), then allow (any match
|
First explicit match in the bottom-up walk is the answer. Default-deny
|
||||||
→ allowed). First explicit match in the bottom-up walk is the
|
if `HasAnyFile=true` and nothing matches.
|
||||||
answer. Default-deny if `HasAnyFile=true` and nothing matches.
|
|
||||||
|
A built-in `/.admin/effective-policy?path=...&email=...` endpoint that does
|
||||||
|
this trace and returns the chain + decision is on the future-work list (see
|
||||||
|
below); until it ships, the manual procedure is the only path.
|
||||||
|
|
||||||
### Directory visibility
|
### Directory visibility
|
||||||
|
|
||||||
|
|
@ -566,15 +552,10 @@ have to redo the gap analysis from scratch.
|
||||||
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
||||||
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
||||||
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
||||||
- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now
|
- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config`
|
||||||
sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2
|
defaults; no explicit `MinVersion`, `CipherSuites`, or curve list.
|
||||||
AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305
|
Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred),
|
||||||
variants), curve preferences (X25519, P-256, P-384), and emits HSTS
|
DoD-approved cipher allowlist, OCSP stapling, HSTS header.
|
||||||
(`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
|
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
|
||||||
network-level isolation only. Required: mTLS or signed forwarding token
|
network-level isolation only. Required: mTLS or signed forwarding token
|
||||||
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
||||||
|
|
@ -585,16 +566,10 @@ have to redo the gap analysis from scratch.
|
||||||
allow/deny + a single root-admin role. Required: roles as first-class
|
allow/deny + a single root-admin role. Required: roles as first-class
|
||||||
entities, `.zddc` syntax for role grants, identity-source-driven role
|
entities, `.zddc` syntax for role grants, identity-source-driven role
|
||||||
assignment.
|
assignment.
|
||||||
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
|
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny
|
||||||
Leaf-allow-overrides-parent-deny is the cascade's intentional
|
is incompatible with default federal expectations. Required: a configurable
|
||||||
delegation behavior in commercial mode and is preserved in the
|
enforcement mode where parent denies are absolute and only root admins can
|
||||||
internal Go evaluator. For federal deployments, `--print-rego=federal`
|
override.
|
||||||
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
|
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||||
documented integration with at least one IdP supporting federal identity
|
documented integration with at least one IdP supporting federal identity
|
||||||
|
|
@ -720,36 +695,20 @@ cache lookup would be.
|
||||||
|
|
||||||
### Reference Rego policy
|
### Reference Rego policy
|
||||||
|
|
||||||
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
The `--print-rego` flag emits the bundled reference Rego policy that
|
||||||
variants ship:
|
mirrors internal-mode semantics exactly. Federal customers standing up
|
||||||
|
their own OPA instance can use it as a starting point:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
zddc-server --print-rego # standard cascade (commercial)
|
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
||||||
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`
|
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
||||||
imports the OPA Go module **as a test-only dependency**, evaluates both
|
imports the OPA Go module **as a test-only dependency**, evaluates the
|
||||||
bundled Regos against fixture sets and asserts:
|
bundled Rego against the same fixture set the internal Go evaluator
|
||||||
|
runs, and fails CI on any divergence. The test-only import means the
|
||||||
- The standard Rego matches the internal Go evaluator on every documented
|
production binary stays OPA-free (still 13 MB) — the OPA library is in
|
||||||
cascade scenario (`TestRegoParity_AllInternalCases`).
|
`go.mod` but not in `go build`'s output.
|
||||||
- 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
|
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
|
where the production decider is pure Go (no library bloat, no extra
|
||||||
|
|
@ -794,6 +753,9 @@ the *interpretation* of those files differs per tenant.
|
||||||
Items the conversation flagged as friction in operator setup or as documented
|
Items the conversation flagged as friction in operator setup or as documented
|
||||||
gaps that warrant code, in addition to the federal-readiness items above:
|
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
|
- `.zddc.form.yaml` ACL editor (built on the form-data system) once
|
||||||
file-as-truth round-trip preserves comments — turn the manual YAML edit
|
file-as-truth round-trip preserves comments — turn the manual YAML edit
|
||||||
into a self-service UI for project owners.
|
into a self-service UI for project owners.
|
||||||
|
|
@ -811,30 +773,22 @@ gaps that warrant code, in addition to the federal-readiness items above:
|
||||||
|
|
||||||
## Admin Debug Page
|
## Admin Debug Page
|
||||||
|
|
||||||
`zddc-server` exposes a built-in debug page at `/.profile/` for operators who can
|
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
||||||
push code/images but cannot `kubectl exec` into the running container. It surfaces:
|
push code/images but cannot `kubectl exec` into the running container. It surfaces:
|
||||||
|
|
||||||
- **`/.profile/whoami`** — every header on the current request, the configured email
|
- **`/.admin/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
|
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
|
first thing to look at when access logs show `email=anonymous` — it tells you
|
||||||
exactly which (if any) header the upstream proxy is sending.
|
exactly which (if any) header the upstream proxy is sending.
|
||||||
- **`/.profile/config`** — the resolved `Config` (env vars). Equivalent to
|
- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to
|
||||||
`kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides.
|
`kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides.
|
||||||
- **`/.profile/logs`** — recent log entries (last 500) from an in-memory ring buffer.
|
- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer.
|
||||||
Optional `?level=info|warn|error|debug` and `?since=<RFC3339>` query params.
|
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
|
At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under
|
||||||
`msg=request headers` — useful for diagnosing proxy / SSO header passthrough
|
`msg=request headers` — useful for diagnosing proxy / SSO header passthrough
|
||||||
(e.g. confirming which header carries the email). Note: that dump includes
|
(e.g. confirming which header carries the email). Note: that dump includes
|
||||||
auth tokens and cookies; only enable debug in trusted environments.
|
auth tokens and cookies; only enable debug in trusted environments.
|
||||||
- **`/.profile/effective-policy?path=...&email=...`** — cascade tracer.
|
- **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side.
|
||||||
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
|
### Authorization
|
||||||
|
|
||||||
|
|
@ -934,7 +888,7 @@ For any path, the resolution order is:
|
||||||
3. **Embedded** — the build-time HTML compiled into the binary.
|
3. **Embedded** — the build-time HTML compiled into the binary.
|
||||||
|
|
||||||
URL sources are fetched once on first request and cached forever in
|
URL sources are fetched once on first request and cached forever in
|
||||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
|
`<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh and no
|
||||||
hash verification — to pull a new build, delete the cache file. Concurrent
|
hash verification — to pull a new build, delete the cache file. Concurrent
|
||||||
misses for the same URL share one outbound fetch (singleflight). Direct
|
misses for the same URL share one outbound fetch (singleflight). Direct
|
||||||
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
|
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,13 @@ import (
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// --print-rego: dump a bundled reference Rego policy and exit.
|
// --print-rego: dump the bundled reference Rego policy and exit.
|
||||||
// Cheap escape hatch for operators standing up an external OPA who want
|
// Cheap escape hatch for operators standing up an external OPA who want
|
||||||
// a parity-tested baseline as a starting point for customization.
|
// the 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:] {
|
for _, a := range os.Args[1:] {
|
||||||
switch a {
|
if a == "--print-rego" {
|
||||||
case "--print-rego", "--print-rego=standard":
|
|
||||||
fmt.Print(policy.ReferenceRego)
|
fmt.Print(policy.ReferenceRego)
|
||||||
return
|
return
|
||||||
case "--print-rego=federal":
|
|
||||||
fmt.Print(policy.FederalRego)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,20 +146,9 @@ func main() {
|
||||||
"url", cfg.OPAURL,
|
"url", cfg.OPAURL,
|
||||||
"cache_ttl", cfg.OPACacheTTL)
|
"cache_ttl", cfg.OPACacheTTL)
|
||||||
|
|
||||||
// Innermost handler: dispatch.
|
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
dispatch(cfg, idx, logRing, appsServer, w, r)
|
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()
|
gzWrapper, err := newGzipWrapper()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -37,27 +37,7 @@ func NewCache(root string) (*Cache, error) {
|
||||||
func (c *Cache) Root() string { return c.root }
|
func (c *Cache) Root() string { return c.root }
|
||||||
|
|
||||||
// keyForURL converts a URL into a relative filesystem path under the
|
// keyForURL converts a URL into a relative filesystem path under the
|
||||||
// cache root.
|
// cache root, e.g. "zddc.varasys.io/releases/archive_stable.html".
|
||||||
//
|
|
||||||
// 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) {
|
func keyForURL(rawURL string) (string, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -72,15 +52,13 @@ func keyForURL(rawURL string) (string, error) {
|
||||||
if u.RawQuery != "" {
|
if u.RawQuery != "" {
|
||||||
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
|
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
|
||||||
}
|
}
|
||||||
// Lowercase the host part but preserve the port verbatim. Without
|
host := strings.ToLower(u.Host)
|
||||||
// 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 {
|
if i := strings.Index(host, ":"); i >= 0 {
|
||||||
host = strings.ToLower(host[:i]) + host[i:]
|
port := host[i+1:]
|
||||||
} else {
|
hostOnly := host[:i]
|
||||||
host = strings.ToLower(host)
|
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
|
||||||
|
host = hostOnly
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p := u.Path
|
p := u.Path
|
||||||
for strings.Contains(p, "//") {
|
for strings.Contains(p, "//") {
|
||||||
|
|
@ -94,7 +72,7 @@ func keyForURL(rawURL string) (string, error) {
|
||||||
if strings.Contains(cleaned, "..") {
|
if strings.Contains(cleaned, "..") {
|
||||||
return "", fmt.Errorf("URL path contains '..'")
|
return "", fmt.Errorf("URL path contains '..'")
|
||||||
}
|
}
|
||||||
return u.Scheme + "/" + host + cleaned, nil
|
return host + cleaned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) pathFor(rawURL string) (string, error) {
|
func (c *Cache) pathFor(rawURL string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,14 @@ func TestKeyForURL(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
raw, want string
|
raw, want string
|
||||||
}{
|
}{
|
||||||
// Default ports are PRESERVED — no port-stripping (the previous
|
{"https://zddc.varasys.io/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
|
||||||
// behavior conflated "operator wrote :443" with "operator wrote
|
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
|
||||||
// bare host"; with the full origin in the key, every URL maps
|
{"http://example.com:80/foo.html", "example.com/foo.html"},
|
||||||
// to exactly one path).
|
{"https://example.com:443/foo.html", "example.com/foo.html"},
|
||||||
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
{"https://example.com:8443/foo.html", "example.com:8443/foo.html"},
|
||||||
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
{"https://example.com/", "example.com/index.html"},
|
||||||
{"http://example.com/foo.html", "http/example.com/foo.html"},
|
{"https://example.com", "example.com/index.html"},
|
||||||
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
|
{"https://example.com//foo//bar.html", "example.com/foo/bar.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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.raw, func(t *testing.T) {
|
t.Run(tc.raw, func(t *testing.T) {
|
||||||
|
|
@ -45,38 +33,6 @@ 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) {
|
func TestKeyForURL_Errors(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -91,26 +91,6 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||||
return n, err
|
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
|
// AccessLogMiddleware logs a structured line per HTTP request after the
|
||||||
// response is written.
|
// response is written.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package handler
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
@ -64,12 +63,6 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serveProfileLogs(ring, w, r)
|
serveProfileLogs(ring, w, r)
|
||||||
case "/effective-policy":
|
|
||||||
if !zddc.IsAdmin(cfg.Root, email) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveProfileEffectivePolicy(cfg, w, r)
|
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|
@ -257,180 +250,3 @@ func levelRank(s string) int {
|
||||||
return 1 // unknown → info
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,6 @@ func TestServeProfileGateMatrix(t *testing.T) {
|
||||||
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
|
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
|
||||||
{"admin /.profile/config", "/.profile/config", "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},
|
{"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.
|
// Unknown sub-route still 404.
|
||||||
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
|
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
|
||||||
|
|
@ -475,86 +469,6 @@ 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) {
|
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
||||||
// .zddc exists but has no admins list — page is still reachable,
|
// .zddc exists but has no admins list — page is still reachable,
|
||||||
// but the admin/super-admin sections are absent.
|
// but the admin/super-admin sections are absent.
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,9 +15,7 @@ import _ "embed"
|
||||||
// Customizations typical for federal deployments:
|
// Customizations typical for federal deployments:
|
||||||
//
|
//
|
||||||
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
||||||
// are absolute (NIST AC-6 least-privilege posture). For this specific
|
// are absolute (NIST AC-6 least-privilege posture).
|
||||||
// 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
|
// - Add role-based access via additional input fields (input.user.roles
|
||||||
// populated by the upstream proxy from SAML/OIDC claims).
|
// populated by the upstream proxy from SAML/OIDC claims).
|
||||||
// - Add time-of-day or IP-range constraints.
|
// - Add time-of-day or IP-range constraints.
|
||||||
|
|
@ -26,21 +24,3 @@ import _ "embed"
|
||||||
//
|
//
|
||||||
//go:embed rego/access.rego
|
//go:embed rego/access.rego
|
||||||
var ReferenceRego string
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# 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)
|
|
||||||
}
|
|
||||||
|
|
@ -38,32 +38,6 @@ func TLSConfig(cfg config.Config) (*tls.Config, bool, error) {
|
||||||
return &tls.Config{
|
return &tls.Config{
|
||||||
Certificates: []tls.Certificate{cert},
|
Certificates: []tls.Certificate{cert},
|
||||||
MinVersion: tls.VersionTLS12,
|
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
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue