Compare commits
4 commits
9d5430db81
...
5c33c8a821
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c33c8a821 | |||
| a01315fd00 | |||
| e911806eda | |||
| 6b973906c3 |
24 changed files with 2301 additions and 101 deletions
|
|
@ -391,7 +391,11 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
|
||||
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. |
|
||||
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). |
|
||||
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `<ZDDC_ROOT>/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. |
|
||||
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
|
||||
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
|
||||
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
|
||||
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
|
||||
|
||||
### Release tagging
|
||||
|
|
@ -420,7 +424,7 @@ local path that fails loudly and visibly on the developer's terminal.
|
|||
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
|
||||
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
||||
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
||||
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
|
||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
||||
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
||||
|
|
|
|||
|
|
@ -443,6 +443,73 @@ app.state.subscribe((property, newValue) => {
|
|||
|
||||
---
|
||||
|
||||
## Server security model
|
||||
|
||||
zddc-server is the deployable surface — it gates access to the file tree that
|
||||
all the HTML tools work against. The trust story is intentionally narrow,
|
||||
delegated, and small enough to audit. This section frames it; operator detail
|
||||
(syntax, anti-patterns, worked layouts, verification recipe, federal-readiness
|
||||
gap analysis) lives in [`zddc/README.md`](zddc/README.md) § "Access control:
|
||||
the `.zddc` cascade."
|
||||
|
||||
### Cooperating layers
|
||||
|
||||
Six layers cooperate to bound what a request can reach. Each does one job;
|
||||
none of them is load-bearing alone.
|
||||
|
||||
| Layer | Job | Implementation |
|
||||
|---|---|---|
|
||||
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
|
||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
|
||||
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` |
|
||||
|
||||
### Commercial vs federal trust model
|
||||
|
||||
The current implementation is well-shaped for a commercial-tenant model with
|
||||
delegated auth. Federal-grade qualification (FedRAMP Moderate, NIST 800-53,
|
||||
FIPS 140-3, DoD STIG) requires several layers to harden. Operators deciding
|
||||
whether to deploy the system should know which column they're in.
|
||||
|
||||
| Property | Commercial trust model (current) | Federal trust model (gaps to close) |
|
||||
|---|---|---|
|
||||
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
|
||||
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
||||
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
||||
| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) |
|
||||
| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) |
|
||||
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
||||
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
||||
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
||||
| Disclosure process | Not formalized | `SECURITY.md`, embargoed-fix workflow, CVE assignment |
|
||||
|
||||
The full bullet list with NIST control references is in
|
||||
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
|
||||
|
||||
### Why the tool-rooted view matters for third-party containment
|
||||
|
||||
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool
|
||||
served at that URL, with that subtree as its world. There is no breadcrumb to
|
||||
`/Archive/`, no "go to root" button, no listing of sibling vendor folders.
|
||||
This is **UX containment** — it makes the deployment feel self-contained and
|
||||
prevents accidental discovery of out-of-scope paths. It is **not** the
|
||||
security mechanism. Even if a vendor hand-crafts a URL to `/Archive/` or
|
||||
`/`, the cascade's default-deny rejects them before any byte is served. The
|
||||
tool-rooted view exists so a vendor reading their email link doesn't have to
|
||||
choose between "click something I shouldn't" and "click nothing"; the
|
||||
cascade exists so that choice never matters.
|
||||
|
||||
This pairing — UI affordance contained, ACL enforced — is why the server
|
||||
auto-serves `archive.html` at *every* directory under `ZDDC_ROOT`. If the
|
||||
archive tool only worked at root, every locked-down subtree would need
|
||||
either a hand-crafted entrypoint or a tool that knew how to scope itself.
|
||||
Auto-serving makes "vendor's world starts at `/Archive/<their-name>/`" the
|
||||
out-of-the-box behavior with no per-deployment configuration.
|
||||
|
||||
---
|
||||
|
||||
## CSS Architecture
|
||||
|
||||
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ zddc:
|
|||
rootPath: /srv
|
||||
addr: ":8080"
|
||||
emailHeader: X-Auth-Request-Email
|
||||
corsOrigin: "https://zddc.varasys.io,http://localhost:8000"
|
||||
# Empty (default) disables CORS — fine for embedded-tools / same-origin.
|
||||
# Dev typically keeps localhost in here for the iterate-on-tool-builds
|
||||
# workflow where you load a tool from `./dev-server start` (8000) and
|
||||
# point it at this server. Add other tool-host origins as needed.
|
||||
corsOrigin: "http://localhost:8000"
|
||||
logLevel: debug # full request headers logged; sensitive!
|
||||
indexPath: ".archive"
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,13 @@ zddc:
|
|||
# Email-header convention from your authenticating reverse proxy.
|
||||
emailHeader: X-Auth-Request-Email
|
||||
|
||||
# Comma-separated CORS allowlist. Set to your tools host, or empty
|
||||
# to disable CORS entirely (when tools are same-origin).
|
||||
corsOrigin: "https://zddc.varasys.io"
|
||||
# Comma-separated CORS allowlist. Empty (default) disables CORS —
|
||||
# appropriate for the embedded-tools install path where tools are
|
||||
# served same-origin by zddc-server itself. Set to a specific origin
|
||||
# only if browser-loaded pages from a different host call back into
|
||||
# this server (e.g. self-hosted tools at https://tools.acme.com,
|
||||
# or the CDN-bootstrap pattern from https://zddc.varasys.io).
|
||||
corsOrigin: ""
|
||||
|
||||
# info / warn / error / debug. Production stays on info; debug logs
|
||||
# every request's full header map (includes cookies/auth tokens).
|
||||
|
|
|
|||
694
zddc/README.md
694
zddc/README.md
|
|
@ -57,30 +57,47 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to
|
|||
| `ZDDC_TLS_CERT` | *(empty)* | Path to PEM certificate file. `none` = plain HTTP (no TLS); empty = generate self-signed |
|
||||
| `ZDDC_TLS_KEY` | *(empty)* | Path to PEM private key file. Required when `ZDDC_TLS_CERT` is a file path; ignored otherwise |
|
||||
| `ZDDC_INSECURE_DIRECT` | *(empty)* | Must be `1` when `ZDDC_TLS_CERT=none` and the bind address is non-loopback. Acknowledges that an authenticating reverse proxy is in front of zddc-server; without it, plain-HTTP non-loopback startup is refused |
|
||||
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup when `<ZDDC_ROOT>/.zddc` is missing. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default access. Set only for deliberately-public deployments |
|
||||
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` = built-in Go evaluator (default). `http(s)://...` or `unix:///...` = external OPA-compatible server (federal deployments using their own audited Rego). See "External policy decider" below. |
|
||||
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = on transport error, allow the request (availability over correctness). Default = fail closed (deny). Never set to `1` in federal contexts. |
|
||||
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — bursts of identical queries (a single `.archive` listing can hit the same `(email, dir)` tuple many times) collapse to one OPA round-trip. Set `0` to disable. Format is Go's `time.ParseDuration` (`500ms`, `2s`, `1m`). |
|
||||
| `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty value disables CORS entirely. Default lets ZDDC tools served from `zddc.varasys.io` (e.g. via the bootstrap pattern) call back into your deployed server. |
|
||||
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty (default) disables CORS — appropriate when zddc-server's embedded tools serve same-origin. Set explicitly only if browser-loaded pages from a different origin call back into this server (e.g. `https://tools.acme.com` for self-hosted tools, or `https://zddc.varasys.io` for the CDN-bootstrap pattern) |
|
||||
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | Tee'd structured access log. Auto-mkdir on first run. Empty value (set explicitly with `--access-log=`) disables file logging; stderr stream stays. Per-host filenames let multiple replicas write to the same `.zddc.d/` directory without collision; every record carries a `host` field for downstream aggregation. |
|
||||
|
||||
`ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates.
|
||||
|
||||
### CORS
|
||||
|
||||
The default `ZDDC_CORS_ORIGIN=https://zddc.varasys.io` exists so the canonical
|
||||
ZDDC tool builds (hosted at `zddc.varasys.io`) can call back into your
|
||||
deployed `zddc-server` without extra configuration. If you self-host the
|
||||
tools on your own domain (e.g. `tools.acme.com`), set:
|
||||
CORS is **disabled by default** — `ZDDC_CORS_ORIGIN` defaults to empty.
|
||||
The embedded-tools install path serves tools and data from the same origin
|
||||
(both come from zddc-server itself), so no cross-origin allowlist is needed
|
||||
and there is no implicit cross-origin trust to a third-party host.
|
||||
|
||||
Set the value explicitly only when browser-loaded pages from a *different*
|
||||
origin need to call back into this server. Two scenarios:
|
||||
|
||||
```sh
|
||||
# Self-hosted tools on a separate domain
|
||||
ZDDC_CORS_ORIGIN=https://tools.acme.com
|
||||
|
||||
# CDN-bootstrap pattern (loading tools from the canonical upstream and
|
||||
# pointing them at your server) — opt in to this trust explicitly
|
||||
ZDDC_CORS_ORIGIN=https://zddc.varasys.io
|
||||
```
|
||||
|
||||
Multiple origins are comma-separated. To disable CORS entirely (e.g. when
|
||||
all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The
|
||||
middleware echoes the matched origin back per-request and sets
|
||||
`Access-Control-Allow-Credentials: true` so the upstream-set
|
||||
`X-Auth-Request-Email` header crosses the boundary.
|
||||
Multiple origins are comma-separated. The middleware echoes the matched
|
||||
origin back per-request and sets `Access-Control-Allow-Credentials: true`
|
||||
so the upstream-set `X-Auth-Request-Email` header crosses the boundary.
|
||||
|
||||
> Why empty by default? Earlier releases defaulted this to
|
||||
> `https://zddc.varasys.io` for the CDN-bootstrap convenience, but every
|
||||
> deployment then implicitly trusted that origin to make authenticated
|
||||
> cross-origin XHRs on behalf of any logged-in user. That's an unusual
|
||||
> trust assumption to bake into a default. Now you opt in explicitly when
|
||||
> you actually need it.
|
||||
|
||||
## TLS
|
||||
|
||||
|
|
@ -119,46 +136,76 @@ zddc-server does **not** perform authentication itself. It reads the user's emai
|
|||
from a request header (default: `X-Auth-Request-Email`) that must be set by an upstream reverse proxy
|
||||
(nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user.
|
||||
|
||||
If the header is absent, the user is treated as anonymous (empty email). A directory with
|
||||
no `.zddc` rules is publicly accessible; a directory with an allowlist requires a matching
|
||||
email.
|
||||
If the header is absent, the user is treated as anonymous (empty email). A request is
|
||||
allowed only if (a) **no `.zddc` file exists anywhere in the chain from `ZDDC_ROOT` to
|
||||
the requested directory** (a fresh tree with zero `.zddc` files defaults to public
|
||||
access — see warning at the top of the next section), or (b) some level in the chain
|
||||
explicitly allows the caller's email. See "Access control: the `.zddc` cascade" below
|
||||
for the full evaluation order.
|
||||
|
||||
## `.zddc` Access Control Files
|
||||
## Access control: the `.zddc` cascade
|
||||
|
||||
Place a `.zddc` YAML file in any directory to control access. Rules cascade from parent
|
||||
directories — child rules are appended to (not replaced by) parent rules.
|
||||
> ⚠️ **zddc-server refuses to start without a root `.zddc`.** A `ZDDC_ROOT` containing
|
||||
> no `.zddc` files anywhere would default to allow-all (anonymous callers included),
|
||||
> so the server fails fast at startup with a clear error. Pass `--insecure` (or
|
||||
> `ZDDC_INSECURE=1`) to acknowledge a deliberately-public deployment, otherwise
|
||||
> drop a starter `<ZDDC_ROOT>/.zddc` per "Step 1" below.
|
||||
|
||||
`zddc-server` enforces access via cascading `.zddc` YAML files: drop one in any
|
||||
directory, and its rules apply to that directory and everything beneath it that doesn't
|
||||
override. The model is small enough to hold in your head, but the cascade has one
|
||||
asymmetry that bites operators on first contact — read "When the cascade helps and
|
||||
when it fights you" below before designing a layout.
|
||||
|
||||
```yaml
|
||||
# Example .zddc file
|
||||
acl:
|
||||
allow:
|
||||
- "*@mycompany.com" # all users at mycompany.com
|
||||
- "*@mycompany.com" # everyone at mycompany.com
|
||||
- "contractor@partner.com" # specific external user
|
||||
deny:
|
||||
- "intern@mycompany.com" # override: block this specific user
|
||||
- "intern@mycompany.com" # override: block this specific user
|
||||
```
|
||||
|
||||
### ACL evaluation order
|
||||
### Step 1: starter `.zddc`
|
||||
|
||||
Rules are evaluated **bottom-up**: starting at the requested directory and walking
|
||||
toward the root. The first explicit match (allow or deny) at any level wins.
|
||||
Every install should write a root `.zddc` before exposing the bind address. The
|
||||
minimum is an `admins:` line so the admin debug page works (see "Admin Debug Page"
|
||||
below) — adding `acl:` is optional at this step:
|
||||
|
||||
1. Check deny patterns at the current level — if email matches → **403 Forbidden**
|
||||
2. Check allow patterns at the current level — if email matches → **allow**
|
||||
3. No match at this level → walk up to parent directory and repeat
|
||||
4. If no `.zddc` files were found anywhere in the chain → **allow** (public, no rules)
|
||||
5. If `.zddc` files exist but email matched nothing → **403 Forbidden** (not on any list)
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/.zddc — bare minimum
|
||||
admins:
|
||||
- you@yourcompany.com
|
||||
```
|
||||
|
||||
This model supports three user tiers in a single tree:
|
||||
With this single file in place, `HasAnyFile` becomes `true` for every directory in
|
||||
the tree and the default switches from "allow-all-anonymous" to "deny-anything-not-
|
||||
explicitly-allowed." From here you grant access by adding `acl:` rules at the levels
|
||||
that need them. (See worked examples below.)
|
||||
|
||||
| Level | Rule | Effect |
|
||||
|---|---|---|
|
||||
| Root | `allow: ["*@company.com"]` | All company users see everything |
|
||||
| Project dir | `allow: ["team@company.com"]` | Restricts to the project team |
|
||||
| Vendor subdir | `allow: ["vendor@ext.com"]` | Grants a third-party access to their folder only |
|
||||
### How a request is evaluated
|
||||
|
||||
A vendor navigating to their subdirectory is allowed by the deepest matching rule,
|
||||
even if a higher-level rule would deny them.
|
||||
When a request arrives for `/A/B/C/`, zddc-server reads every `.zddc` file along
|
||||
the chain from `ZDDC_ROOT` down to `/A/B/C/`, then walks **bottom-up** (deepest
|
||||
level first) looking for a match. The first explicit match wins — either an allow
|
||||
or a deny.
|
||||
|
||||
1. **At the current level**, check deny patterns first. If the email matches any
|
||||
deny → **403 Forbidden**, stop walking. *(Important: at the same level, deny
|
||||
beats allow — see anti-patterns below.)*
|
||||
2. **Same level**, check allow patterns. If the email matches → **allow**, stop
|
||||
walking.
|
||||
3. **No match at this level** → walk up to the parent directory's `.zddc` and
|
||||
repeat.
|
||||
4. **No level matched anywhere in the chain:**
|
||||
- If no `.zddc` file existed anywhere in the chain (`HasAnyFile=false`) → **allow** (the empty-tree default; see warning above).
|
||||
- If at least one `.zddc` file existed somewhere in the chain (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
|
||||
|
||||
The two functions implementing this are `AllowedAtLevel` (within-level: deny first,
|
||||
then allow) at `zddc/internal/zddc/acl.go:10` and `AllowedWithChain` (deepest-first
|
||||
walk + default-deny rule) at `zddc/internal/zddc/acl.go:29`. The chain itself is
|
||||
built by `EffectivePolicy` at `zddc/internal/zddc/cascade.go:25`.
|
||||
|
||||
### Glob patterns
|
||||
|
||||
|
|
@ -171,11 +218,233 @@ even if a higher-level rule would deny them.
|
|||
| `*` | Any non-empty email |
|
||||
| `alice@example.com` | Exact match only |
|
||||
|
||||
The `*` does **not** cross the `@`. Implementation at `zddc/internal/zddc/acl.go:52`.
|
||||
|
||||
### When the cascade helps and when it fights you
|
||||
|
||||
The cascade is well-suited to one shape and clumsy at another. Internalize the
|
||||
asymmetry before designing your layout:
|
||||
|
||||
- **Adding a new email at a leaf is easy** (third parties, occasional contractors).
|
||||
The new email doesn't match any rule at higher levels, so the cascade just grants
|
||||
the leaf-level allow and silently default-denies them everywhere else. No extra
|
||||
`deny:` rules needed.
|
||||
- **Excluding insiders from a leaf is harder** (commercially sensitive subset of
|
||||
an otherwise company-wide tree). If a parent has `allow: ["*@company.com"]`, a
|
||||
leaf-level `allow: [alice@company.com]` *adds* alice on top of everyone — it
|
||||
does not subtract everyone-else. Subtracting requires either (a) the two-level
|
||||
gate-and-reallow pattern in the worked example below, or (b) not having the
|
||||
permissive parent rule in the first place.
|
||||
|
||||
This shape is intentional: the cascade is designed for **delegation** (subtree
|
||||
owners can grant access without coordinating with central admin), not for
|
||||
fine-grained subtractive policy. If your model is "everyone has access by default,
|
||||
specific dirs are restricted," push the wildcards downward off the root rather than
|
||||
fighting the cascade.
|
||||
|
||||
### Pick your layout
|
||||
|
||||
| Your shape | Recommended pattern |
|
||||
|---|---|
|
||||
| Solo / single-user archive | One `.zddc` at root with `admins: [you]`, no `acl:` block — root-only restriction |
|
||||
| Single small team, full sharing | Root `.zddc` with `acl: {allow: ["*@team.com"]}`. No subdir overrides needed |
|
||||
| Multi-tenant: each tenant in own subdir | Empty root `.zddc` (just `admins:`), per-tenant `<tenant>/.zddc` with `acl: {allow: [<tenant emails>]}`. Tenants don't see each other's listings |
|
||||
| Mixed: half open within company, half locked-down to a subset, plus third-party folders | The worked example below — careful: do **not** put `*@company.com` at root |
|
||||
|
||||
### Worked example: paired open/closed projects + third-party archive
|
||||
|
||||
This is the deployment shape most operators end up with: technical projects are
|
||||
shared company-wide; their commercial siblings are restricted to a subset; a
|
||||
separate `/Archive/` tree holds per-vendor folders where each vendor sees only
|
||||
their own subdir. The cascade handles all three with no `deny:` rules — but only
|
||||
if you keep the `*@company.com` wildcard *off the root*.
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/.zddc — admins only, no broad ACL
|
||||
admins:
|
||||
- admin@mycompany.com
|
||||
```
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/Acme-tech/.zddc — open employee project (technical)
|
||||
acl:
|
||||
allow: ["*@mycompany.com"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/Acme-comm/.zddc — closed sibling (commercially sensitive)
|
||||
acl:
|
||||
allow:
|
||||
- alice@mycompany.com
|
||||
- bob@mycompany.com
|
||||
```
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/Archive/.zddc — employees can browse the vendor list
|
||||
acl:
|
||||
allow: ["*@mycompany.com"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/Archive/Acme/.zddc — vendor's only window
|
||||
acl:
|
||||
allow:
|
||||
- acme-rep@acme.com
|
||||
```
|
||||
|
||||
Trace for an insider (`alice@mycompany.com`) and a vendor (`acme-rep@acme.com`)
|
||||
hitting representative paths:
|
||||
|
||||
| Path | alice@mycompany.com | acme-rep@acme.com |
|
||||
|---|---|---|
|
||||
| `/Acme-tech/` | ✅ matches `*@mycompany.com` at this level | ❌ no match anywhere; `HasAnyFile=true` → 403 |
|
||||
| `/Acme-comm/` | ✅ matches `alice` at this level | ❌ default-deny |
|
||||
| `/<other-closed-project>/` | ❌ no match anywhere → 403 | ❌ default-deny |
|
||||
| `/Archive/` | ✅ matches `*@mycompany.com` | ❌ no match; default-deny |
|
||||
| `/Archive/Acme/` | ✅ falls up to `/Archive/.zddc`, matches | ✅ matches `acme-rep@acme.com` at this level |
|
||||
| `/Archive/Acme/Incoming/` | ✅ inherits from `/Archive/.zddc` | ✅ inherits from `/Archive/Acme/.zddc` |
|
||||
| `/Archive/<other-vendor>/` | ✅ inherits from `/Archive/` | ❌ no match; default-deny |
|
||||
| `/` (project picker) | Lands; sees the projects she has access to | Lands; project picker filtering hides everything she can't reach |
|
||||
|
||||
The vendor reaches `/Archive/Acme/...` only via direct URL (a bookmark or a
|
||||
transmittal email link). Trying to navigate up to `/Archive/` returns 403; the
|
||||
archive tool itself treats `/Archive/Acme/` as the root of *its* world (see
|
||||
"Tool-rooted view" in `ARCHITECTURE.md` § Server security model), so there is
|
||||
no breadcrumb leading anywhere they can't see.
|
||||
|
||||
### Patterns that look secure but aren't
|
||||
|
||||
These are the traps. Each is plausible at first glance and doesn't behave as
|
||||
naive intuition suggests.
|
||||
|
||||
1. **Same-level `allow + deny "*@company.com"` does NOT lock the level down to the
|
||||
allow's targets.** Deny is checked before allow within a single `.zddc`, so the
|
||||
allowed user's email matches the deny first and is blocked. To exclude insiders
|
||||
from a leaf, use the two-level gate-and-reallow (parent denies, deeper level
|
||||
re-allows) — *or* avoid putting `*@company.com` at any ancestor.
|
||||
|
||||
```yaml
|
||||
# /Closed/.zddc — DOES NOT WORK as intended
|
||||
acl:
|
||||
allow: [alice@company.com]
|
||||
deny: ["*@company.com"] # blocks alice too — deny is checked first at same level
|
||||
```
|
||||
|
||||
2. **A leaf-level `allow: [subset]` does NOT restrict when a parent has
|
||||
`allow: ["*@domain"]`.** Non-subset users hit the leaf with no match, walk up,
|
||||
match the parent wildcard, and are allowed. Adding a leaf allow is *additive*,
|
||||
never *subtractive*. (See the asymmetry section above.)
|
||||
|
||||
3. **`admins:` in any `.zddc` other than the root is silently ignored.** The check
|
||||
at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is
|
||||
the only upward-escalation gate; subtree write access never grants admin.
|
||||
|
||||
4. **An `apps:` URL override is a full UI mount, not just a tool version pin.**
|
||||
Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...`
|
||||
and serve arbitrary HTML to every viewer below that level. Subtree write
|
||||
authority on `.zddc` should be treated as full UI-mounting authority. The
|
||||
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
|
||||
`<ZDDC_ROOT>/_app/<host>/<path>`. (See "Apps: virtual tool HTMLs" below for
|
||||
the resolver order; SHA-256 pinning is on the federal-readiness list, not
|
||||
currently implemented.)
|
||||
|
||||
5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders'
|
||||
existence.** Sibling-vendor names are hidden because directories the caller
|
||||
can't access are omitted from listings (see "Directory visibility" below) —
|
||||
that's the actual mechanism. Don't rely on the parent dir being denied; rely
|
||||
on the listing filter.
|
||||
|
||||
### Trust model and invariants
|
||||
|
||||
These are the invariants security reviewers should expect to find, stated plainly
|
||||
and tied to the code that enforces them:
|
||||
|
||||
- **Auth boundary.** zddc-server does not authenticate. The user's email is read
|
||||
from the configured request header (default `X-Auth-Request-Email`) set by an
|
||||
upstream reverse proxy. If the proxy is misconfigured or the bind address is
|
||||
reachable without traversing the proxy, every request is anonymous — and any
|
||||
caller able to set the header can claim any email. Network isolation between
|
||||
the proxy and zddc-server is required (see "Trust boundary" below).
|
||||
- **Subtree authority.** Whoever can write a `.zddc` controls that subtree's
|
||||
ACL — including overriding a parent *deny* with a leaf *allow* (test:
|
||||
`zddc/internal/zddc/acl_test.go:212` "leaf allows user that parent denies →
|
||||
leaf wins"). This is intentional delegation, not a bug. If you grant write
|
||||
access to `/Project-A/.zddc`, you've granted full ACL authority over the
|
||||
Project-A subtree.
|
||||
- **Root-only escalation gate.** `admins:` is honored *only* at
|
||||
`<ZDDC_ROOT>/.zddc`. Subtree `admins:` entries are silently ignored
|
||||
(`zddc/internal/zddc/file.go:17-20`). This is the *only* upward escalation
|
||||
block; without it, anyone with subtree write authority could promote
|
||||
themselves to admin.
|
||||
|
||||
### Trust boundary
|
||||
|
||||
What zddc-server enforces stops at the network boundary. The deployment must
|
||||
guarantee these for the model above to hold:
|
||||
|
||||
1. **The bind address must be reachable only via the authenticating proxy.** The
|
||||
email-header trust assumes the proxy is the only path to the server. In
|
||||
Kubernetes: a `NetworkPolicy` restricting ingress to the proxy pod. On a
|
||||
single host: bind to loopback (`ZDDC_ADDR=127.0.0.1:8080`) and run the proxy
|
||||
on the same host. **Without this**, anyone reaching the bind address can
|
||||
forge any email by setting `X-Auth-Request-Email` themselves. zddc-server
|
||||
refuses to start with `ZDDC_TLS_CERT=none` on a non-loopback bind unless
|
||||
`ZDDC_INSECURE_DIRECT=1` is set as an explicit acknowledgement that an
|
||||
authenticating proxy is enforcing this.
|
||||
2. **Anonymous information disclosure on `/` is by design.** The public landing
|
||||
page returns a project picker filtered by ACL — anonymous callers see only
|
||||
projects with no `.zddc` rules along their chain (in a properly-configured
|
||||
deployment, none). The *existence* of the server and the names of any
|
||||
ACL-free projects are disclosed without authentication. For deployments
|
||||
where this disclosure is unacceptable, gate `/` itself behind the proxy's
|
||||
auth-required path; zddc-server's public-landing logic does not need to be
|
||||
disabled, it simply never receives an anonymous request. `/.profile` is also
|
||||
reachable anonymously by design — same caveat applies.
|
||||
3. **Audit log integrity is filesystem-level, not application-level.** The
|
||||
tee'd JSON access log lives at `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`
|
||||
on the served volume. While `.`-prefixed paths return 404 over HTTP (so the
|
||||
log is not readable through zddc-server), anything with **filesystem write
|
||||
authority on the served volume** (a sidecar, a backup-restore process, an
|
||||
admin shell) can modify log entries after the fact. For tamper-evident
|
||||
logging, ship the JSON-line file to an external append-only sink (syslog,
|
||||
SIEM) via a sidecar; do not treat the local rotation as the system of
|
||||
record.
|
||||
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
|
||||
miss, cached at `<ZDDC_ROOT>/_app/<host>/<path>` forever — no SHA-256 pin,
|
||||
no signature. Use only URLs you control, treat the apps cache as a trust
|
||||
boundary, and audit who has `.zddc` write authority where.
|
||||
|
||||
### Debugging permissions
|
||||
|
||||
When a user reports "I can't see /Project-X/" and you need to figure out why,
|
||||
manual cascade-tracing is the current path:
|
||||
|
||||
1. **Confirm the resolved email** — hit `/.admin/whoami` as the user (you'll
|
||||
need to have proxy auth working, or run the request *through* the proxy
|
||||
that signs them in). The page shows every header on the request and the
|
||||
`email` field zddc-server resolved.
|
||||
2. **List the chain.** From `<ZDDC_ROOT>` down to the requested directory,
|
||||
inspect each `.zddc` (most directories have none). For
|
||||
`/Project-X/sub/sub/`, that's `/.zddc`, `/Project-X/.zddc`,
|
||||
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists.
|
||||
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny
|
||||
patterns first (any match → blocked), then allow (any match → allowed).
|
||||
First explicit match in the bottom-up walk is the answer. Default-deny
|
||||
if `HasAnyFile=true` and nothing matches.
|
||||
|
||||
A built-in `/.admin/effective-policy?path=...&email=...` endpoint that does
|
||||
this trace and returns the chain + decision is on the future-work list (see
|
||||
below); until it ships, the manual procedure is the only path.
|
||||
|
||||
### Directory visibility
|
||||
|
||||
Directories for which the user lacks access are **omitted** from JSON listings entirely —
|
||||
they are neither listed nor queryable. A direct request to a denied path returns `403`.
|
||||
|
||||
This is the mechanism that hides sibling subtrees from a caller. Vendor `acme-rep`
|
||||
sees an empty-looking `/Archive/` (in fact returns 403 since they're not allowed
|
||||
there at all in the worked example), and no other vendor's name leaks via listing.
|
||||
|
||||
### Reserved hidden segments
|
||||
|
||||
Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
||||
|
|
@ -190,6 +459,318 @@ Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
|||
browse to but might link to (e.g. a `_template/` directory of stub-HTML examples
|
||||
to copy into project subdirs).
|
||||
|
||||
### How to verify in 5 minutes
|
||||
|
||||
This recipe stands up the worked-example layout in a tmpdir, hits each
|
||||
(email, path) cell with `curl`, and asserts the documented behavior. Run it on
|
||||
your own deployment to confirm the cascade is doing what you think:
|
||||
|
||||
```sh
|
||||
ROOT=$(mktemp -d)
|
||||
mkdir -p "$ROOT/Acme-tech" "$ROOT/Acme-comm" "$ROOT/Archive/Acme"
|
||||
|
||||
cat > "$ROOT/.zddc" <<'YAML'
|
||||
admins: [admin@mycompany.com]
|
||||
YAML
|
||||
cat > "$ROOT/Acme-tech/.zddc" <<'YAML'
|
||||
acl: {allow: ["*@mycompany.com"]}
|
||||
YAML
|
||||
cat > "$ROOT/Acme-comm/.zddc" <<'YAML'
|
||||
acl: {allow: [alice@mycompany.com]}
|
||||
YAML
|
||||
cat > "$ROOT/Archive/.zddc" <<'YAML'
|
||||
acl: {allow: ["*@mycompany.com"]}
|
||||
YAML
|
||||
cat > "$ROOT/Archive/Acme/.zddc" <<'YAML'
|
||||
acl: {allow: [acme-rep@acme.com]}
|
||||
YAML
|
||||
|
||||
# Plain HTTP on loopback so curl doesn't need TLS
|
||||
ZDDC_ROOT="$ROOT" ZDDC_TLS_CERT=none ZDDC_ADDR=127.0.0.1:8090 \
|
||||
./zddc-server &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
probe() { # email path expected_status
|
||||
got=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "X-Auth-Request-Email: $1" \
|
||||
"http://127.0.0.1:8090$2")
|
||||
printf '%-40s %-30s expected=%s got=%s %s\n' \
|
||||
"$1" "$2" "$3" "$got" \
|
||||
"$([ "$got" = "$3" ] && echo OK || echo FAIL)"
|
||||
}
|
||||
|
||||
# Insider — alice should see all the technical + her closed project + Archive tree
|
||||
probe alice@mycompany.com /Acme-tech/ 200
|
||||
probe alice@mycompany.com /Acme-comm/ 200
|
||||
probe alice@mycompany.com /Archive/ 200
|
||||
probe alice@mycompany.com /Archive/Acme/ 200
|
||||
|
||||
# Insider not on the closed project — bob should hit the technical and Archive,
|
||||
# NOT the closed sibling
|
||||
probe bob@mycompany.com /Acme-tech/ 200
|
||||
probe bob@mycompany.com /Acme-comm/ 403
|
||||
probe bob@mycompany.com /Archive/ 200
|
||||
probe bob@mycompany.com /Archive/Acme/ 200
|
||||
|
||||
# Vendor — acme-rep should ONLY see /Archive/Acme/, blocked everywhere else
|
||||
probe acme-rep@acme.com /Acme-tech/ 403
|
||||
probe acme-rep@acme.com /Acme-comm/ 403
|
||||
probe acme-rep@acme.com /Archive/ 403
|
||||
probe acme-rep@acme.com /Archive/Acme/ 200
|
||||
|
||||
# Anonymous — root .zddc exists, so HasAnyFile=true → default-deny everywhere
|
||||
# (the root / itself is the public-landing exception; subdirs are gated)
|
||||
probe '' /Acme-tech/ 403
|
||||
probe '' /Archive/Acme/ 403
|
||||
|
||||
# Anti-pattern: same-level allow + deny *@company.com does NOT lock alice in
|
||||
mkdir -p "$ROOT/Trap"
|
||||
cat > "$ROOT/Trap/.zddc" <<'YAML'
|
||||
acl:
|
||||
allow: [alice@mycompany.com]
|
||||
deny: ["*@mycompany.com"] # deny is checked first → blocks alice too
|
||||
YAML
|
||||
probe alice@mycompany.com /Trap/ 403 # the trap docs warn about
|
||||
|
||||
kill $SERVER_PID
|
||||
```
|
||||
|
||||
Every line should print `OK`. If any prints `FAIL`, the cascade isn't behaving
|
||||
as documented — file an issue with the failing line. **After every `.zddc`
|
||||
change in production**, retest at minimum as the editing user (to confirm you
|
||||
haven't locked yourself out).
|
||||
|
||||
### Federal-readiness gap analysis
|
||||
|
||||
The current model is well-suited for commercial-tenant ACL with delegated
|
||||
authentication. To clear federal hurdles (FedRAMP Moderate, NIST 800-53 Rev. 5
|
||||
baseline, FIPS 140-3, DoD STIG), these gaps would need to be closed. None are
|
||||
implemented today — this list is informational so security reviewers don't
|
||||
have to redo the gap analysis from scratch.
|
||||
|
||||
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
||||
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
||||
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
||||
- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config`
|
||||
defaults; no explicit `MinVersion`, `CipherSuites`, or curve list.
|
||||
Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred),
|
||||
DoD-approved cipher allowlist, OCSP stapling, HSTS header.
|
||||
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
|
||||
network-level isolation only. Required: mTLS or signed forwarding token
|
||||
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
||||
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
|
||||
proxy. Required: documented reference deployment with PIV/CAC via
|
||||
oauth2-proxy or equivalent.
|
||||
- **Role-based access control** (NIST AC-3(7)) — current model is per-email
|
||||
allow/deny + a single root-admin role. Required: roles as first-class
|
||||
entities, `.zddc` syntax for role grants, identity-source-driven role
|
||||
assignment.
|
||||
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny
|
||||
is incompatible with default federal expectations. Required: a configurable
|
||||
enforcement mode where parent denies are absolute and only root admins can
|
||||
override.
|
||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||
documented integration with at least one IdP supporting federal identity
|
||||
attestation.
|
||||
- **Audit log integrity & retention** (NIST AU-9, AU-11) — current 90-day
|
||||
local rotation is a starting point. Required: tamper-evident logs (signed
|
||||
log chain or external append-only sink) with 1-year minimum online and
|
||||
3-year archive.
|
||||
- **Continuous monitoring hooks** (NIST CA-7) — automated alerting on
|
||||
`.zddc` changes, admin endpoint use, repeated 403s from one identity.
|
||||
Required: structured event emission to syslog/SIEM beyond the local file.
|
||||
- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip,
|
||||
docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline.
|
||||
`apps:` URL fetches need SHA-256 pinning and signature verification.
|
||||
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
|
||||
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
|
||||
key-rotation procedures.
|
||||
- **Vulnerability disclosure process** (NIST SI-5) — repo lacks
|
||||
`SECURITY.md`. Required: documented disclosure procedure, embargoed-fix
|
||||
workflow, CVE-assignment policy.
|
||||
|
||||
A full SSP / control-by-control mapping consumes this list as input; it is
|
||||
not a substitute for one.
|
||||
|
||||
### External policy decider (OPA-compatible)
|
||||
|
||||
For deployments that need policy decisions made by an external,
|
||||
independently-audited engine — typically federal customers using
|
||||
[Open Policy Agent](https://www.openpolicyagent.org/) — zddc-server can
|
||||
delegate every access decision to an HTTP/Unix-socket endpoint that
|
||||
speaks OPA's `/v1/data/...` JSON wire protocol.
|
||||
|
||||
Set one of:
|
||||
|
||||
```sh
|
||||
ZDDC_OPA_URL=internal # built-in Go evaluator (default)
|
||||
ZDDC_OPA_URL=http://127.0.0.1:8181 # OPA via HTTP
|
||||
ZDDC_OPA_URL=https://opa.internal:8181 # OPA via HTTPS
|
||||
ZDDC_OPA_URL=unix:///run/opa/opa.sock # OPA via Unix socket
|
||||
```
|
||||
|
||||
Internal mode uses zddc-server's in-process evaluator — same Go code that
|
||||
backs the cascade rules above, no network round-trip, no external dependency.
|
||||
This is the default.
|
||||
|
||||
External mode POSTs each access decision to
|
||||
`<ZDDC_OPA_URL>/v1/data/zddc/access/allow` with body:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"user": {"email": "alice@mycompany.com"},
|
||||
"path": "/Project-A/sub/",
|
||||
"policy_chain": {
|
||||
"levels": [
|
||||
{"acl": {}, "admins": ["admin@mycompany.com"]},
|
||||
{"acl": {"allow": ["*@mycompany.com"]}}
|
||||
],
|
||||
"has_any_file": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OPA evaluates the deployment's Rego policy against this input and returns:
|
||||
|
||||
```json
|
||||
{"result": true}
|
||||
```
|
||||
|
||||
Rego policy authors can implement either:
|
||||
|
||||
- **Same semantics as our internal evaluator** — walk
|
||||
`input.policy_chain.levels` bottom-up, deny-first within a level,
|
||||
default-deny when `input.policy_chain.has_any_file` is true. The
|
||||
`.zddc` files in the served tree continue to drive policy unchanged.
|
||||
- **Federal-mode tightening** — same chain, but parent denies are
|
||||
absolute (no leaf-allow override of an ancestor's deny). NIST AC-6
|
||||
least-privilege posture.
|
||||
- **RBAC-from-IdP** — read additional fields from `input.user`
|
||||
(e.g. `roles` populated by the upstream proxy from SAML/OIDC claims)
|
||||
and decide based on those, treating `.zddc` as a file-tree map of
|
||||
required-roles instead of explicit emails.
|
||||
- **Anything else** — Rego is general-purpose policy; once you're
|
||||
running real OPA, the constraints are whatever you write.
|
||||
|
||||
### OPA failure modes
|
||||
|
||||
External mode adds a network call to the request hot path. zddc-server
|
||||
treats unreachable / non-2xx / malformed-response cases as **deny**
|
||||
(fail-closed) by default, with a `WARN` log. Operators who prefer
|
||||
availability over correctness — typically not federal — can flip this:
|
||||
|
||||
```sh
|
||||
ZDDC_OPA_FAIL_OPEN=1 # allow on transport error
|
||||
```
|
||||
|
||||
Always-WARN logging means a healthy run is silent and a sick OPA is
|
||||
loud regardless of which mode you pick.
|
||||
|
||||
### OPA decision cache
|
||||
|
||||
External OPA mode wraps the HTTP/socket client in a small per-decision
|
||||
cache (default 1 s TTL). A single `.archive` listing or directory
|
||||
enumeration walks the cascade for every entry, which would otherwise
|
||||
issue one OPA round-trip per entry; the cache collapses identical
|
||||
`(email, decision-input)` tuples down to one call per TTL window.
|
||||
|
||||
The 1-second default is short enough that a `.zddc` edit is reflected
|
||||
in the next listing (it's the same window as the fsnotify watcher's
|
||||
debounce). Operators who want zero staleness — or who are running
|
||||
their own caching layer in front of OPA — can disable:
|
||||
|
||||
```sh
|
||||
ZDDC_OPA_CACHE_TTL=0 # no caching, every request → OPA
|
||||
ZDDC_OPA_CACHE_TTL=5s # longer window for batchy workloads
|
||||
```
|
||||
|
||||
The cache is per-process, in-memory, and capped at ~4096 entries with
|
||||
opportunistic eviction of expired entries. Internal mode (the default)
|
||||
gets no cache — the in-process Go evaluator is already cheaper than a
|
||||
cache lookup would be.
|
||||
|
||||
### Reference Rego policy
|
||||
|
||||
The `--print-rego` flag emits the bundled reference Rego policy that
|
||||
mirrors internal-mode semantics exactly. Federal customers standing up
|
||||
their own OPA instance can use it as a starting point:
|
||||
|
||||
```sh
|
||||
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
||||
```
|
||||
|
||||
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
||||
imports the OPA Go module **as a test-only dependency**, evaluates the
|
||||
bundled Rego against the same fixture set the internal Go evaluator
|
||||
runs, and fails CI on any divergence. The test-only import means the
|
||||
production binary stays OPA-free (still 13 MB) — the OPA library is in
|
||||
`go.mod` but not in `go build`'s output.
|
||||
|
||||
This gives you both ends of the spectrum: a single OPA-aware codebase
|
||||
where the production decider is pure Go (no library bloat, no extra
|
||||
process), the wire format is OPA-canonical (just point an external OPA
|
||||
at it and decisions delegate seamlessly), and the bundled reference
|
||||
Rego is a parity-tested artifact you can ship alongside or extend.
|
||||
|
||||
Typical federal customizations on top of the bundled Rego:
|
||||
|
||||
- **Parent-deny-is-absolute** — flip the leaf-allow-overrides-parent-deny
|
||||
rule for NIST AC-6 least-privilege posture.
|
||||
- **Role-based access** — read additional input fields like
|
||||
`input.user.roles` populated by the upstream proxy from SAML/OIDC
|
||||
claims, and decide based on those instead of (or alongside) email.
|
||||
- **Time-of-day or IP-range constraints** — Rego can read
|
||||
`input.context.now` and request metadata for context-aware
|
||||
decisions.
|
||||
- **SIEM-shipped decision logs** — OPA's logging plugins emit every
|
||||
decision in a structured format ready for Splunk Government, Elastic
|
||||
Federal, etc.
|
||||
|
||||
### Reference deployment shapes
|
||||
|
||||
**Commercial / default**: nothing to set. `ZDDC_OPA_URL=internal` is
|
||||
the implicit default; the in-process evaluator handles every decision.
|
||||
No sidecar, no extra port, no extra binary.
|
||||
|
||||
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
|
||||
nomad task, or systemd service on the same host), bind it to
|
||||
`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA
|
||||
loads the deployment's bundled Rego policy from a configured source
|
||||
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
||||
independently of zddc-server.
|
||||
|
||||
**Per-tenant policy variants**: run multiple OPA instances each loaded
|
||||
with a different bundle, point each zddc-server replica at the
|
||||
appropriate one. The `.zddc` files in the served tree stay the same;
|
||||
the *interpretation* of those files differs per tenant.
|
||||
|
||||
### Future work
|
||||
|
||||
Items the conversation flagged as friction in operator setup or as documented
|
||||
gaps that warrant code, in addition to the federal-readiness items above:
|
||||
|
||||
- `/.admin/effective-policy?path=...&email=...` endpoint returning the
|
||||
resolved chain + decision, so debugging permissions stops requiring manual
|
||||
cascade tracing.
|
||||
- `.zddc.form.yaml` ACL editor (built on the form-data system) once
|
||||
file-as-truth round-trip preserves comments — turn the manual YAML edit
|
||||
into a self-service UI for project owners.
|
||||
- Save-time validation that warns when a `.zddc` change would lock the
|
||||
editing user out (or have a measurable effect they didn't anticipate).
|
||||
- `zddc-server policy export` command emitting every `.zddc` file's resolved
|
||||
effect, suitable for change-control review (and a prerequisite for the
|
||||
CM-3 federal control above).
|
||||
- Per-decision caching for external OPA mode (small TTL on (email, path)
|
||||
to amortize the .archive listing's per-entry round-trip).
|
||||
- A reference Rego bundle shipped alongside the binary that exactly
|
||||
reproduces internal mode, plus a "federal-mode" variant that flips
|
||||
the parent-deny-is-absolute toggle. Useful as a starting point for
|
||||
customers who want to extend rather than write from scratch.
|
||||
|
||||
## Admin Debug Page
|
||||
|
||||
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
||||
|
|
@ -223,9 +804,10 @@ acl:
|
|||
- "*@mycompany.com"
|
||||
```
|
||||
|
||||
Only the root-level `admins` entry is honored — subdirectory `.zddc` files'
|
||||
`admins` keys are ignored. Otherwise anyone with subtree write access could
|
||||
elevate themselves.
|
||||
The root-only invariant (subdirectory `admins:` entries are silently ignored, so
|
||||
subtree write authority cannot be self-promoted to admin) is documented under
|
||||
"Trust model and invariants" in the access-control section above — that's the
|
||||
canonical home; this section just shows the syntax.
|
||||
|
||||
If the root `.zddc` has no `admins` list (or no `.zddc` exists), every admin
|
||||
endpoint returns **404** to every caller. Non-admin requests also receive 404
|
||||
|
|
@ -345,6 +927,42 @@ server-side state required.
|
|||
users can email direct links to a pre-filtered view. If the recipient does not have
|
||||
access to a project listed in the URL, a warning banner is shown.
|
||||
|
||||
## Caching and ETags
|
||||
|
||||
zddc-server uses content-hash ETags wherever a re-fetch of identical
|
||||
content is plausible — directory listings, the project list, the
|
||||
embedded HTML tools.
|
||||
|
||||
| Endpoint | ETag source | Notes |
|
||||
|---|---|---|
|
||||
| `GET /` (project list, `Accept: application/json`) | SHA-256 prefix of the response body | Refetched on every request; the JSON is rebuilt from current FS state. The hash is the actual response, so 304s are always trustworthy regardless of filesystem-watcher reliability. |
|
||||
| `GET /<dir>/` (directory listing, `Accept: application/json`) | SHA-256 prefix of the response body | Same. |
|
||||
| `GET /<dir>/` (HTML browse) | Hash of the embedded `browse.html` template | Computed once at startup, memoized. Changes only on binary redeploy. |
|
||||
| `GET /<app>.html` (embedded tools) | Hash of the embedded bytes | Same — memoized at startup. |
|
||||
|
||||
**Why content-hash and not server-side caching?** The cascade walks
|
||||
`.zddc` files on every directory access; an `os.ReadDir` runs to build
|
||||
the listing; the ACL filter applies. A cache keyed on directory mtime
|
||||
would save that server work but depends on reliable filesystem
|
||||
watching — and Azure Files SMB mounts (a common deployment substrate)
|
||||
do not support `inotify`/`fsnotify` reliably. Content-hash ETags
|
||||
deliver only the bandwidth savings, not the server-work savings, but
|
||||
they cannot lie about staleness regardless of watcher behavior. A
|
||||
future enhancement could add an mtime-keyed cache for environments
|
||||
with reliable watchers, behind a feature flag.
|
||||
|
||||
The response headers are:
|
||||
|
||||
```
|
||||
Cache-Control: private, max-age=0, must-revalidate
|
||||
ETag: "<16-hex-char hash>"
|
||||
```
|
||||
|
||||
`must-revalidate` ensures every refresh round-trips the server (which
|
||||
re-runs the cascade and ACL filter); `max-age=0` means no client-side
|
||||
freshness window; `private` prevents intermediary caches from sharing
|
||||
responses across users (each user has their own ACL-filtered view).
|
||||
|
||||
## Access Logging
|
||||
|
||||
Every HTTP request is logged as a structured `slog` entry at `INFO` level:
|
||||
|
|
@ -481,7 +1099,7 @@ Downstream Helm charts and Compose files should set these explicitly:
|
|||
| `ZDDC_INSECURE_DIRECT` | `1` | Acknowledge plain HTTP behind a trusted proxy |
|
||||
| `ZDDC_ADDR` | `:8080` | Match service / probe port |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header your auth proxy sets |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://your-host` | Origins permitted to call back into the server |
|
||||
| `ZDDC_CORS_ORIGIN` | *(unset)* | Leave unset for embedded-tools deployments (same-origin); set to your tool host (`https://tools.acme.com`) only for self-hosted-tools or CDN-bootstrap layouts |
|
||||
|
||||
See "Environment Variables" above for the full list.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
||||
|
|
@ -31,6 +32,16 @@ import (
|
|||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// --print-rego: dump the bundled reference Rego policy and exit.
|
||||
// Cheap escape hatch for operators standing up an external OPA who want
|
||||
// the parity-tested baseline as a starting point for customization.
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--print-rego" {
|
||||
fmt.Print(policy.ReferenceRego)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load(os.Args[1:])
|
||||
if errors.Is(err, config.ErrHelpRequested) {
|
||||
config.Usage(os.Stderr)
|
||||
|
|
@ -110,7 +121,32 @@ func main() {
|
|||
// CORSMiddleware — Origin / preflight handling.
|
||||
// dispatch — the actual request handler.
|
||||
auditLogger := setupAccessAuditLog(cfg.AccessLog)
|
||||
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Construct the policy decider once at startup. ZDDC_OPA_URL=internal
|
||||
// (default) routes decisions through the in-process Go evaluator;
|
||||
// http(s):// or unix:// values send each decision to an external
|
||||
// OPA-compatible server (federal customers, custom Rego policies).
|
||||
deciderCfg := policy.Config{
|
||||
URL: cfg.OPAURL,
|
||||
FailOpen: cfg.OPAFailOpen,
|
||||
CacheTTL: cfg.OPACacheTTL,
|
||||
}
|
||||
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
|
||||
// the policy package's sentinel for "skip the wrapper").
|
||||
if deciderCfg.CacheTTL == 0 {
|
||||
deciderCfg.CacheTTL = -1
|
||||
}
|
||||
decider, err := policy.New(deciderCfg)
|
||||
if err != nil {
|
||||
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("policy decider ready",
|
||||
"mode", policyModeLabel(cfg.OPAURL),
|
||||
"url", cfg.OPAURL,
|
||||
"cache_ttl", cfg.OPACacheTTL)
|
||||
|
||||
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dispatch(cfg, idx, logRing, appsServer, w, r)
|
||||
})))))
|
||||
|
||||
|
|
@ -187,6 +223,23 @@ func main() {
|
|||
// (running user only). For multi-user audit access, the operator should
|
||||
// use group-readable parent directory permissions and either chmod the
|
||||
// log out-of-band or run a forwarder that has its own read access.
|
||||
// policyModeLabel collapses cfg.OPAURL to a one-word mode label for the
|
||||
// startup log so operators can grep for the active decider quickly.
|
||||
func policyModeLabel(opaURL string) string {
|
||||
switch {
|
||||
case opaURL == "" || strings.EqualFold(opaURL, "internal"):
|
||||
return "internal"
|
||||
case strings.HasPrefix(opaURL, "unix://"):
|
||||
return "external-unix"
|
||||
case strings.HasPrefix(opaURL, "https://"):
|
||||
return "external-https"
|
||||
case strings.HasPrefix(opaURL, "http://"):
|
||||
return "external-http"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func setupAccessAuditLog(path string) *slog.Logger {
|
||||
if path == "" {
|
||||
return nil
|
||||
|
|
@ -424,7 +477,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -449,7 +502,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
isRoot := urlPath == "/"
|
||||
if !isRoot {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -464,7 +517,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
|
||||
// Regular file: ACL on parent directory
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
34
zddc/go.mod
34
zddc/go.mod
|
|
@ -5,8 +5,40 @@ go 1.24
|
|||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/open-policy-agent/opa v0.70.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.26.0 // indirect
|
||||
require (
|
||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tchap/go-patricia/v2 v2.3.1 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/yashtewari/glob-intersection v0.2.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
|
|
|||
148
zddc/go.sum
148
zddc/go.sum
|
|
@ -1,12 +1,158 @@
|
|||
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
|
||||
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
|
||||
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
|
||||
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
|
||||
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
|
||||
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
|
||||
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U=
|
||||
github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
|
||||
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
|
||||
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration. Each field can be set via a
|
||||
|
|
@ -23,8 +24,12 @@ type Config struct {
|
|||
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
||||
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
|
||||
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
|
||||
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
|
||||
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
|
||||
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
|
||||
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
|
||||
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
|
||||
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
||||
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
|
||||
}
|
||||
|
||||
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||
|
|
@ -72,9 +77,17 @@ func Load(args []string) (Config, error) {
|
|||
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
"HTTP header carrying the authenticated user's email.")
|
||||
corsOriginFlag := fs.String("cors-origin", "",
|
||||
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
|
||||
"Comma-separated CORS allowlist. Empty (default) = CORS disabled. Set to your tool-host origin (e.g. https://tools.acme.com) only if browser-loaded pages from that origin call back into this server.")
|
||||
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
||||
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
|
||||
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1",
|
||||
"Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
|
||||
opaURLFlag := fs.String("opa-url", getEnv("ZDDC_OPA_URL", "internal"),
|
||||
"Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".")
|
||||
opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
|
||||
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
|
||||
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
|
||||
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
|
||||
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
||||
"Tee structured access logs to this file (JSON, size-rotated). "+
|
||||
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
||||
|
|
@ -127,6 +140,10 @@ func Load(args []string) (Config, error) {
|
|||
EmailHeader: *emailHeaderFlag,
|
||||
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||
AccessLog: *accessLogFlag,
|
||||
Insecure: *insecureFlag,
|
||||
OPAURL: *opaURLFlag,
|
||||
OPAFailOpen: *opaFailOpenFlag,
|
||||
OPACacheTTL: *opaCacheTTLFlag,
|
||||
}
|
||||
|
||||
// Default Root to the current working directory.
|
||||
|
|
@ -146,6 +163,24 @@ func Load(args []string) (Config, error) {
|
|||
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
|
||||
}
|
||||
|
||||
// Refuse to start when the served tree has no root .zddc file. With no
|
||||
// .zddc anywhere in the chain, AllowedWithChain falls through to its
|
||||
// "HasAnyFile=false → allow" default, so every directory is publicly
|
||||
// accessible to anonymous callers. The vast majority of operators do not
|
||||
// want that — and the few who do (a deliberately public archive) can pass
|
||||
// --insecure to acknowledge it. See zddc/README.md § Access control.
|
||||
if !cfg.Insecure {
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, ".zddc")); os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf(
|
||||
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
|
||||
"Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+
|
||||
"or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment",
|
||||
cfg.Root)
|
||||
} else if err != nil {
|
||||
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-log default: if neither flag nor env was explicitly set,
|
||||
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
|
||||
// server captures an audit trail by default. Setting the flag/env
|
||||
|
|
@ -211,8 +246,12 @@ func Usage(w io.Writer) {
|
|||
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
|
||||
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
|
||||
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
|
||||
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
|
||||
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = CORS disabled.")
|
||||
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
|
||||
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
|
||||
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
|
||||
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
|
||||
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
|
||||
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
|
||||
fs.Bool("help", false, "Print this help and exit.")
|
||||
fs.Bool("version", false, "Print version info and exit.")
|
||||
|
|
@ -222,7 +261,14 @@ func Usage(w io.Writer) {
|
|||
// resolveCORS implements the precedence rules for the CORS allowlist:
|
||||
// - flag explicitly set → use flag value (empty = disabled)
|
||||
// - else env var explicitly set → use env value (empty = disabled)
|
||||
// - else → default to the canonical upstream
|
||||
// - else → default to nil (CORS disabled)
|
||||
//
|
||||
// Default-empty is intentional: the embedded-tools deployment path (the install
|
||||
// default) serves tools and data from the same origin, so CORS is unneeded.
|
||||
// Operators who deliberately load tools from a different origin (e.g. the
|
||||
// CDN-bootstrap pattern from https://zddc.varasys.io, or self-hosted at
|
||||
// https://tools.acme.com) opt in by setting the value explicitly. This avoids
|
||||
// implicit cross-origin trust on third-party domains.
|
||||
func resolveCORS(flagSet bool, flagValue string) []string {
|
||||
if flagSet {
|
||||
return parseCSV(flagValue)
|
||||
|
|
@ -230,7 +276,7 @@ func resolveCORS(flagSet bool, flagValue string) []string {
|
|||
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
|
||||
return parseCSV(v)
|
||||
}
|
||||
return []string{"https://zddc.varasys.io"}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCSV splits a comma-separated list and trims whitespace. Empty
|
||||
|
|
@ -276,3 +322,16 @@ func getEnv(key, fallback string) string {
|
|||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// parseDurationOrDefault parses a duration string ("1s", "500ms", "0", etc.).
|
||||
// Returns def on empty input or parse error. Used for env-var defaults
|
||||
// that need a sensible fallback rather than a hard error on typo.
|
||||
func parseDurationOrDefault(s string, def time.Duration) time.Duration {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,17 @@ func TestIsLoopbackAddr(t *testing.T) {
|
|||
|
||||
func TestLoad(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Drop a placeholder .zddc so subtests using this root don't trip the
|
||||
// "no root .zddc → refuse to start" safety check. Tests that explicitly
|
||||
// exercise the missing-.zddc path use a dedicated tmpdir without one.
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed root .zddc: %v", err)
|
||||
}
|
||||
|
||||
// Pre-set the env so each subtest can override what it needs.
|
||||
type envSet map[string]string
|
||||
clearAll := func() {
|
||||
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
|
||||
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_INSECURE", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +65,10 @@ func TestLoad(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "missing root defaults to CWD",
|
||||
env: envSet{},
|
||||
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test
|
||||
// is specifically about Root resolution falling back to CWD, not
|
||||
// about the .zddc safety check.
|
||||
env: envSet{"ZDDC_INSECURE": "1"},
|
||||
// ZDDC_ROOT not set → Load falls back to os.Getwd().
|
||||
check: func(t *testing.T, cfg Config) {
|
||||
cwd, err := os.Getwd()
|
||||
|
|
@ -95,8 +104,8 @@ func TestLoad(t *testing.T) {
|
|||
if cfg.EmailHeader != "X-Auth-Request-Email" {
|
||||
t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader)
|
||||
}
|
||||
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" {
|
||||
t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins)
|
||||
if len(cfg.CORSOrigins) != 0 {
|
||||
t.Errorf("CORSOrigins = %v, want empty (CORS disabled by default)", cfg.CORSOrigins)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -237,10 +246,23 @@ func TestLoad(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// tmpRootWithZddc creates a temp dir and seeds a minimal .zddc so the
|
||||
// post-Root-stat safety check (refuse to start with no root .zddc) does
|
||||
// not fire. Tests that exercise the safety check explicitly use
|
||||
// t.TempDir() directly without seeding.
|
||||
func tmpRootWithZddc(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed root .zddc: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var.
|
||||
func TestLoadFlags_OverrideEnv(t *testing.T) {
|
||||
envRoot := t.TempDir()
|
||||
flagRoot := t.TempDir()
|
||||
envRoot := tmpRootWithZddc(t)
|
||||
flagRoot := tmpRootWithZddc(t)
|
||||
os.Setenv("ZDDC_ROOT", envRoot)
|
||||
defer os.Unsetenv("ZDDC_ROOT")
|
||||
|
||||
|
|
@ -255,7 +277,7 @@ func TestLoadFlags_OverrideEnv(t *testing.T) {
|
|||
|
||||
// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults.
|
||||
func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := tmpRootWithZddc(t)
|
||||
cfg, err := Load([]string{
|
||||
"--root", root,
|
||||
"--addr", "127.0.0.1:9999",
|
||||
|
|
@ -282,7 +304,7 @@ func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
|
|||
|
||||
// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS.
|
||||
func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := tmpRootWithZddc(t)
|
||||
cfg, err := Load([]string{"--root", root, "--cors-origin", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
|
|
@ -311,6 +333,10 @@ func TestLoadFlags_VersionRequested(t *testing.T) {
|
|||
// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD.
|
||||
func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
|
||||
os.Unsetenv("ZDDC_ROOT")
|
||||
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test is
|
||||
// specifically about the CWD fallback, not the .zddc safety check.
|
||||
os.Setenv("ZDDC_INSECURE", "1")
|
||||
defer os.Unsetenv("ZDDC_INSECURE")
|
||||
cfg, err := Load([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
|
|
@ -320,3 +346,47 @@ func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
|
|||
t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingRootZddcRefusesStartByDefault: with no .zddc at root and no
|
||||
// --insecure, Load refuses to start (the public-by-default footgun).
|
||||
func TestLoad_MissingRootZddcRefusesStartByDefault(t *testing.T) {
|
||||
root := t.TempDir() // no .zddc seeded
|
||||
os.Setenv("ZDDC_ROOT", root)
|
||||
defer os.Unsetenv("ZDDC_ROOT")
|
||||
_, err := Load([]string{})
|
||||
if err == nil {
|
||||
t.Fatal("Load() = nil error, want error about missing root .zddc")
|
||||
}
|
||||
if !strings.Contains(err.Error(), ".zddc") || !strings.Contains(err.Error(), "publicly accessible") {
|
||||
t.Errorf("Load() error = %v, want substring about missing .zddc and public access", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingRootZddcAllowedWithInsecure: --insecure allows startup
|
||||
// when the root .zddc is missing (acknowledges the public-tree shape).
|
||||
func TestLoad_MissingRootZddcAllowedWithInsecure(t *testing.T) {
|
||||
root := t.TempDir() // no .zddc seeded
|
||||
os.Setenv("ZDDC_ROOT", root)
|
||||
os.Setenv("ZDDC_INSECURE", "1")
|
||||
defer os.Unsetenv("ZDDC_ROOT")
|
||||
defer os.Unsetenv("ZDDC_INSECURE")
|
||||
cfg, err := Load([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.Insecure {
|
||||
t.Errorf("cfg.Insecure = false, want true (set via ZDDC_INSECURE=1)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingRootZddcAllowedWithInsecureFlag: same but via --insecure flag.
|
||||
func TestLoad_MissingRootZddcAllowedWithInsecureFlag(t *testing.T) {
|
||||
root := t.TempDir() // no .zddc seeded
|
||||
cfg, err := Load([]string{"--root", root, "--insecure"})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.Insecure {
|
||||
t.Errorf("cfg.Insecure = false, want true (set via --insecure)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
|
|
@ -30,7 +32,14 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
|
|||
// - dirPath="" means the root of the served tree
|
||||
//
|
||||
// baseURL should end with "/" and is the URL prefix for this directory.
|
||||
func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
|
||||
//
|
||||
// The decider is queried per subdirectory; nil falls back to the internal
|
||||
// Go evaluator (policy.InternalDecider) for tests that don't wire up
|
||||
// an explicit decider.
|
||||
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
|
||||
if decider == nil {
|
||||
decider = &policy.InternalDecider{}
|
||||
}
|
||||
absDir, ok := safeJoin(fsRoot, dirPath)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
|
|
@ -62,7 +71,12 @@ func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileIn
|
|||
// ACL check for subdirectory
|
||||
subAbs := filepath.Join(absDir, name)
|
||||
chain, err := zddc.EffectivePolicy(fsRoot, subAbs)
|
||||
if err != nil || !zddc.AllowedWithChain(chain, userEmail) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subURLPath := baseURL + name + "/"
|
||||
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
|
||||
if !allowed {
|
||||
continue // omit denied directories silently
|
||||
}
|
||||
fi := listing.FileInfo{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ import (
|
|||
// filename: the part after .archive/ (empty for directory listing)
|
||||
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
||||
email := EmailFromContext(r)
|
||||
decider := DeciderFromContext(r)
|
||||
ctx := r.Context()
|
||||
|
||||
project := projectFromContextPath(contextPath)
|
||||
if project == "" {
|
||||
|
|
@ -48,7 +51,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("ACL policy error", "path", absDir, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -72,7 +75,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -95,6 +98,8 @@ func projectFromContextPath(contextPath string) string {
|
|||
}
|
||||
|
||||
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
|
||||
decider := DeciderFromContext(r)
|
||||
ctx := r.Context()
|
||||
allEntries := idx.AllEntries(project)
|
||||
archiveBase := contextPath
|
||||
if !strings.HasSuffix(archiveBase, "/") {
|
||||
|
|
@ -117,7 +122,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
|
|||
aclCache[fileDir] = false
|
||||
return false
|
||||
}
|
||||
v := zddc.AllowedWithChain(chain, email)
|
||||
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
|
||||
aclCache[fileDir] = v
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -11,9 +13,19 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// listingETag returns a hex-encoded SHA-256 prefix of the rendered JSON
|
||||
// listing body. Truncated to 16 chars (64 bits) — collisions on a
|
||||
// listing of any realistic size are vanishingly unlikely, and the short
|
||||
// header keeps the wire footprint trim.
|
||||
func listingETag(body []byte) string {
|
||||
h := sha256.Sum256(body)
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot.
|
||||
// Returns ("", false) if relPath would escape fsRoot.
|
||||
func safeJoin(fsRoot, relPath string) (string, bool) {
|
||||
|
|
@ -35,6 +47,8 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
email := EmailFromContext(r)
|
||||
decider := DeciderFromContext(r)
|
||||
ctx := r.Context()
|
||||
|
||||
// Compute relative dir path (no leading or trailing slash)
|
||||
dirPath := strings.TrimPrefix(urlPath, "/")
|
||||
|
|
@ -54,9 +68,11 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
slog.Warn("ACL policy error", "path", absDir, "err", err)
|
||||
}
|
||||
isRoot := dirPath == ""
|
||||
if !isRoot && !zddc.AllowedWithChain(chain, email) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
if !isRoot {
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accept := r.Header.Get("Accept")
|
||||
|
|
@ -73,7 +89,7 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
// Build base URL for listing entries
|
||||
baseURL := urlPath // relative URLs suffice for JSON listings
|
||||
|
||||
entries, err := appfs.ListDirectory(cfg.Root, dirPath, email, baseURL)
|
||||
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
|
|
@ -92,11 +108,32 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Vary", "Accept")
|
||||
|
||||
if strings.Contains(accept, "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err := json.NewEncoder(w).Encode(entries); err != nil {
|
||||
// Content-hash ETag on the listing payload. Re-fetched on every
|
||||
// request (the cascade is walked, ACL filter applied, JSON
|
||||
// rendered, hashed) — that's the same server work the previous
|
||||
// no-cache version did. The win is on the *response*: identical
|
||||
// listings (e.g. the same vendor refreshing their archive page)
|
||||
// short-circuit to 304 with no body.
|
||||
//
|
||||
// Crucially, this scheme tolerates unreliable filesystem
|
||||
// watching (Azure SMB, network shares with delayed inotify):
|
||||
// the ETag is the actual response hash, not a watcher-derived
|
||||
// invalidation token, so it can never lie about content.
|
||||
body, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
slog.Error("encoding directory listing", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
etag := `"` + listingETag(body) + `"`
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import (
|
|||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -187,7 +188,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -263,7 +264,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -346,7 +347,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
|
|
@ -14,10 +15,19 @@ type contextKey string
|
|||
// EmailKey is the context key for the authenticated user's email.
|
||||
const EmailKey contextKey = "email"
|
||||
|
||||
// DeciderKey is the context key for the request's policy decider.
|
||||
// Set by ACLMiddleware so handlers deep in the stack can issue policy
|
||||
// queries without taking the decider as an explicit parameter. Although
|
||||
// the decider is an app-wide singleton (not per-request state), routing
|
||||
// it through context keeps the call-site signatures stable across the
|
||||
// "swap internal evaluator for external OPA" plumbing change.
|
||||
const DeciderKey contextKey = "policy-decider"
|
||||
|
||||
// ACLMiddleware extracts the user email from the configured header and stores
|
||||
// it in the request context. It does NOT enforce ACL itself — each handler
|
||||
// performs its own ACL check via zddc.EffectivePolicy / zddc.AllowedWithChain.
|
||||
func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
|
||||
// it (along with the policy decider) in the request context. It does NOT
|
||||
// enforce ACL itself — each handler performs its own ACL check via
|
||||
// policy.AllowFromChain.
|
||||
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.Header.Get(cfg.EmailHeader)
|
||||
// DEBUG-level header dump for diagnosing proxy / SSO header
|
||||
|
|
@ -33,6 +43,9 @@ func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
|
|||
"observed", email,
|
||||
"headers", r.Header)
|
||||
ctx := context.WithValue(r.Context(), EmailKey, email)
|
||||
if decider != nil {
|
||||
ctx = context.WithValue(ctx, DeciderKey, decider)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
|
@ -45,6 +58,17 @@ func EmailFromContext(r *http.Request) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// DeciderFromContext extracts the policy decider from the request
|
||||
// context. Returns the internal decider as a fallback if none was
|
||||
// installed — this matches the "no OPA configured" semantics and
|
||||
// keeps test setups that don't install ACLMiddleware functional.
|
||||
func DeciderFromContext(r *http.Request) policy.Decider {
|
||||
if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
|
||||
return v
|
||||
}
|
||||
return &policy.InternalDecider{}
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
|
|||
|
||||
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
||||
// email from the context ACL populated.
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
|
||||
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||
|
|
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
|
||||
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
// Note: no X-Auth-Request-Email header set.
|
||||
|
|
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
|
|||
})
|
||||
|
||||
// Inverted order — the ORIGINAL buggy chain.
|
||||
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, noop))
|
||||
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||
|
|
@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
|||
})
|
||||
|
||||
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(auditLogger, noop))
|
||||
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(auditLogger, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
|
|
@ -40,7 +42,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
|
|||
case "/", "":
|
||||
serveProfilePage(cfg, w, r)
|
||||
case "/access":
|
||||
writeJSON(w, enumerateAccess(cfg, email))
|
||||
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
|
||||
case "/projects":
|
||||
serveProfileProjectsCreate(cfg, w, r)
|
||||
case "/whoami":
|
||||
|
|
@ -85,13 +87,13 @@ type AccessView struct {
|
|||
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
|
||||
// the request hot path — it ships a shell first and the client fetches the
|
||||
// view after first paint.
|
||||
func enumerateAccess(cfg config.Config, email string) AccessView {
|
||||
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
|
||||
view := AccessView{
|
||||
Email: email,
|
||||
EmailHeader: cfg.EmailHeader,
|
||||
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
|
||||
}
|
||||
view.Projects, _ = EnumerateProjects(cfg, email)
|
||||
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
|
||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
||||
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
||||
for _, t := range view.AdminSubtrees {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
|
|
@ -26,24 +28,44 @@ type ProjectInfo struct {
|
|||
// ServeProjectList handles GET / with Accept: application/json.
|
||||
// It returns all top-level directories under cfg.Root that the requesting
|
||||
// user has access to, as a JSON array of ProjectInfo.
|
||||
//
|
||||
// Response carries a content-hash ETag. The landing page polls this
|
||||
// endpoint on every paint, and the response (a small JSON array of
|
||||
// project names + URLs the caller can reach) rarely changes between
|
||||
// polls, so 304s save a meaningful amount of cumulative bandwidth.
|
||||
// The hash is computed from the actual response body, so it tolerates
|
||||
// unreliable filesystem watching.
|
||||
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := EnumerateProjects(cfg, EmailFromContext(r))
|
||||
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err := json.NewEncoder(w).Encode(projects); err != nil {
|
||||
body, err := json.Marshal(projects)
|
||||
if err != nil {
|
||||
slog.Error("encoding project list", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
etag := `"` + listingETag(body) + `"`
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// EnumerateProjects returns the visible top-level projects for the given
|
||||
// caller, reusing the same access logic as ServeProjectList. Exported so
|
||||
// the profile page can render the same list server-side without an HTTP
|
||||
// round-trip.
|
||||
func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
|
||||
// round-trip. A nil decider falls back to the internal Go evaluator.
|
||||
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
|
||||
if decider == nil {
|
||||
decider = &policy.InternalDecider{}
|
||||
}
|
||||
entries, err := os.ReadDir(cfg.Root)
|
||||
if err != nil {
|
||||
slog.Error("reading root directory", "err", err)
|
||||
|
|
@ -69,7 +91,7 @@ func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
|
|||
if err != nil {
|
||||
slog.Warn("ACL policy error", "path", absPath, "err", err)
|
||||
}
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
|
||||
continue
|
||||
}
|
||||
// Title comes from <project>/.zddc — optional, ignored on parse error.
|
||||
|
|
|
|||
164
zddc/internal/policy/parity_test.go
Normal file
164
zddc/internal/policy/parity_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
)
|
||||
|
||||
// TestRegoParity_AllInternalCases validates that the bundled reference
|
||||
// Rego (ReferenceRego, embedded from rego/access.rego) produces the same
|
||||
// allow/deny decision as the InternalDecider for every fixture below.
|
||||
//
|
||||
// This test imports the OPA Go module — but only in a _test.go file, so
|
||||
// it does NOT end up in `go build ./cmd/zddc-server`'s production
|
||||
// binary. Operators get a 13-MB OPA-free binary; CI gets a parity check
|
||||
// that catches semantic drift between the two implementations the moment
|
||||
// it occurs.
|
||||
//
|
||||
// The fixture set is intentionally inherited from acl_test.go so any new
|
||||
// case added there (or a case the conversation surfaces while the docs
|
||||
// are being maintained) is automatically picked up here.
|
||||
func TestRegoParity_AllInternalCases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Compile the reference Rego once; query it for each fixture.
|
||||
preparedQuery, err := rego.New(
|
||||
rego.Query("data.zddc.access.allow"),
|
||||
rego.Module("access.rego", ReferenceRego),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("rego compilation failed: %v", err)
|
||||
}
|
||||
|
||||
allow := func(p ...string) zddc.ZddcFile {
|
||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}}
|
||||
}
|
||||
deny := func(p ...string) zddc.ZddcFile {
|
||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}}
|
||||
}
|
||||
allowDeny := func(a, d []string) zddc.ZddcFile {
|
||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
|
||||
}
|
||||
empty := zddc.ZddcFile{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain zddc.PolicyChain
|
||||
email string
|
||||
path string
|
||||
}{
|
||||
// All canonical cascade cases from acl_test.go — kept as a parallel
|
||||
// list rather than imported because acl_test.go's helpers aren't
|
||||
// exported and we want this file to be self-contained.
|
||||
{"empty chain no files", zddc.PolicyChain{HasAnyFile: false}, "alice@example.com", "/"},
|
||||
{"empty chain no files, anon", zddc.PolicyChain{HasAnyFile: false}, "", "/"},
|
||||
{"files exist no rule matches", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true}, "alice@example.com", "/"},
|
||||
{"leaf allow wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
|
||||
{"leaf deny beats parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), deny("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
|
||||
{"leaf no rule, parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allow("bob@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
|
||||
{"leaf re-allows what parent denied", zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com"), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
|
||||
{"multi-level deepest wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/sub/"},
|
||||
{"empty levels, files present, deny", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: true}, "alice@example.com", "/sub/"},
|
||||
{"empty levels, no files, allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: false}, "alice@example.com", "/sub/"},
|
||||
|
||||
// Glob-pattern parity — exercises email_matches in Rego against
|
||||
// MatchesPattern in Go.
|
||||
{"wildcard local part", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@*")}, HasAnyFile: true}, "alice@anywhere.com", "/"},
|
||||
{"wildcard domain", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true}, "anyone@example.com", "/"},
|
||||
{"exact match", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
|
||||
{"exact does not match anyone else", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "bob@example.com", "/"},
|
||||
{"wildcard does NOT cross @", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
|
||||
{"bare * matches anyone", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*")}, HasAnyFile: true}, "alice@example.com", "/"},
|
||||
|
||||
// Worked-example layout traces (verify-it recipe in zddc/README.md).
|
||||
// Insider on technical project.
|
||||
{"insider on technical", zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
|
||||
allow("*@mycompany.com"),
|
||||
}, HasAnyFile: true}, "alice@mycompany.com", "/Acme-tech/"},
|
||||
// Insider not on closed project.
|
||||
{"insider not on closed", zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
|
||||
allow("alice@mycompany.com"),
|
||||
}, HasAnyFile: true}, "bob@mycompany.com", "/Acme-comm/"},
|
||||
// Vendor at /Archive/Acme/.
|
||||
{"vendor at own folder", zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
|
||||
allow("*@mycompany.com"),
|
||||
allow("acme-rep@acme.com"),
|
||||
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Acme/"},
|
||||
// Vendor blocked at sibling-vendor folder.
|
||||
{"vendor blocked at sibling", zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
|
||||
allow("*@mycompany.com"),
|
||||
allow("beta-rep@beta.com"),
|
||||
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Beta/"},
|
||||
|
||||
// The anti-pattern trap: same-level allow + deny *@company.com
|
||||
// blocks the supposedly-allowed user too (deny is checked first
|
||||
// within a level, so this is a documentation/rego parity check).
|
||||
{"trap same-level allow+deny shadow", zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
|
||||
allowDeny([]string{"alice@mycompany.com"}, []string{"*@mycompany.com"}),
|
||||
}, HasAnyFile: true}, "alice@mycompany.com", "/Trap/"},
|
||||
}
|
||||
|
||||
internal := &InternalDecider{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := AllowInput{Path: tc.path, PolicyChain: chainToSerializable(tc.chain)}
|
||||
input.User.Email = tc.email
|
||||
|
||||
// Run the Go evaluator.
|
||||
goAllow, err := internal.Allow(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("internal: %v", err)
|
||||
}
|
||||
|
||||
// Run the Rego evaluator on identical input.
|
||||
regoInput, err := canonicalInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("encode input: %v", err)
|
||||
}
|
||||
rs, err := preparedQuery.Eval(ctx, rego.EvalInput(regoInput))
|
||||
if err != nil {
|
||||
t.Fatalf("rego eval: %v", err)
|
||||
}
|
||||
if len(rs) == 0 {
|
||||
t.Fatalf("rego: no result")
|
||||
}
|
||||
regoAllow, ok := rs[0].Expressions[0].Value.(bool)
|
||||
if !ok {
|
||||
t.Fatalf("rego: result is not bool: %v", rs[0].Expressions[0].Value)
|
||||
}
|
||||
|
||||
if goAllow != regoAllow {
|
||||
t.Errorf("PARITY BROKEN: internal=%v, rego=%v\n email=%q path=%q\n chain=%+v",
|
||||
goAllow, regoAllow, tc.email, tc.path, tc.chain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalInput serializes the AllowInput through JSON and back so that
|
||||
// the Rego evaluator sees the exact same shape an external OPA would
|
||||
// receive over the wire. Catches accidents where a struct-literal
|
||||
// fixture would expose a Go-only field name (capitalized) that wouldn't
|
||||
// reach a real OPA deployment.
|
||||
func canonicalInput(in AllowInput) (map[string]interface{}, error) {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(strings.NewReader(string(b))).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
322
zddc/internal/policy/policy.go
Normal file
322
zddc/internal/policy/policy.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// Package policy is the access-decision boundary for zddc-server.
|
||||
//
|
||||
// All ACL checks in handlers go through Decider.Allow rather than
|
||||
// calling zddc.AllowedWithChain directly. This lets a deployment
|
||||
// route policy decisions to an external OPA-compatible server
|
||||
// (for federal customers running their own audited Rego policies)
|
||||
// without changing handler code.
|
||||
//
|
||||
// Two implementations:
|
||||
//
|
||||
// - InternalDecider — wraps zddc.AllowedWithChain. The default;
|
||||
// no new dependencies, identical semantics to the legacy code
|
||||
// path. This is what the docs in zddc/README.md describe.
|
||||
//
|
||||
// - HTTPDecider — POSTs to OPA's canonical /v1/data/<package>/allow
|
||||
// endpoint over HTTP or a Unix-domain socket. Federal customers
|
||||
// deploy real OPA alongside zddc-server, write their own Rego,
|
||||
// and point ZDDC_OPA_URL at it.
|
||||
//
|
||||
// Configuration knob:
|
||||
//
|
||||
// ZDDC_OPA_URL= # internal (default)
|
||||
// ZDDC_OPA_URL=internal # internal (explicit)
|
||||
// ZDDC_OPA_URL=http://127.0.0.1:8181 # external HTTP
|
||||
// ZDDC_OPA_URL=https://opa.example:8181 # external HTTPS
|
||||
// ZDDC_OPA_URL=unix:///run/opa/opa.sock # external Unix socket
|
||||
//
|
||||
// Failure mode (external only): unreachable / non-2xx / malformed
|
||||
// response → fail closed (deny), with a WARN log. Operators who
|
||||
// prefer availability over correctness can set ZDDC_OPA_FAIL_OPEN=1
|
||||
// to flip to fail-open with a WARN log instead.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// AllowInput is the canonical input shape for Decider.Allow. It
|
||||
// matches OPA's input convention: a JSON object passed as the
|
||||
// "input" field of a /v1/data/<package>/allow query.
|
||||
//
|
||||
// External Rego policies can:
|
||||
// - read input.user.email (string)
|
||||
// - read input.path (string)
|
||||
// - walk input.policy_chain.levels[].acl.{allow,deny} for
|
||||
// custom cascade semantics, or read the pre-resolved
|
||||
// input.policy_chain.has_any_file when implementing the
|
||||
// same default-deny rule we use internally.
|
||||
type AllowInput struct {
|
||||
User struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
Path string `json:"path"`
|
||||
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
|
||||
}
|
||||
|
||||
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
|
||||
// We don't tag zddc.PolicyChain itself because it's tightly coupled
|
||||
// to the parser; the duplication is one struct.
|
||||
type SerializableChain struct {
|
||||
Levels []zddc.ZddcFile `json:"levels"`
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
}
|
||||
|
||||
func chainToSerializable(c zddc.PolicyChain) *SerializableChain {
|
||||
return &SerializableChain{Levels: c.Levels, HasAnyFile: c.HasAnyFile}
|
||||
}
|
||||
|
||||
// Decider is the access-decision interface every handler uses.
|
||||
type Decider interface {
|
||||
Allow(ctx context.Context, input AllowInput) (bool, error)
|
||||
}
|
||||
|
||||
// Config selects and parameterizes the decider.
|
||||
type Config struct {
|
||||
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
|
||||
FailOpen bool // external mode only: on transport error, allow instead of deny
|
||||
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
|
||||
}
|
||||
|
||||
// New constructs a Decider per cfg.URL semantics.
|
||||
// - "" or "internal" → InternalDecider (no cache — the in-process
|
||||
// evaluator is already cheaper than a cache lookup would be)
|
||||
// - "http(s)://..." → HTTPDecider wrapped in a small per-decision
|
||||
// cache (default 1s TTL — short enough that staleness is bounded
|
||||
// to the same window as fsnotify-debounced index refresh, long
|
||||
// enough to amortize bursty listings like .archive enumeration
|
||||
// into one OPA round-trip per (email, decision-input))
|
||||
// - "unix:///..." → same as http(s), over a Unix socket
|
||||
//
|
||||
// Returns an error if URL is unrecognized.
|
||||
func New(cfg Config) (Decider, error) {
|
||||
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
|
||||
return &InternalDecider{}, nil
|
||||
}
|
||||
var inner Decider
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(cfg.URL, "http://"), strings.HasPrefix(cfg.URL, "https://"):
|
||||
inner, err = newHTTPDecider(cfg.URL, cfg.FailOpen, nil)
|
||||
case strings.HasPrefix(cfg.URL, "unix://"):
|
||||
path := strings.TrimPrefix(cfg.URL, "unix://")
|
||||
dialer := &net.Dialer{Timeout: 2 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "unix", path)
|
||||
},
|
||||
}
|
||||
inner, err = newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport)
|
||||
default:
|
||||
return nil, fmt.Errorf("unrecognized ZDDC_OPA_URL %q (want \"internal\", http(s)://..., or unix:///...)", cfg.URL)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ttl := cfg.CacheTTL
|
||||
if ttl == 0 {
|
||||
ttl = time.Second
|
||||
}
|
||||
if ttl < 0 {
|
||||
// Negative TTL = caching disabled (test seam).
|
||||
return inner, nil
|
||||
}
|
||||
return &cachingDecider{inner: inner, ttl: ttl}, nil
|
||||
}
|
||||
|
||||
// InternalDecider routes Allow through zddc.AllowedWithChain. No
|
||||
// network, no Rego, no new dependencies — same Go evaluator the
|
||||
// existing test suite covers.
|
||||
type InternalDecider struct{}
|
||||
|
||||
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
|
||||
chain := zddc.PolicyChain{}
|
||||
if input.PolicyChain != nil {
|
||||
chain.Levels = input.PolicyChain.Levels
|
||||
chain.HasAnyFile = input.PolicyChain.HasAnyFile
|
||||
}
|
||||
return zddc.AllowedWithChain(chain, input.User.Email), nil
|
||||
}
|
||||
|
||||
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
|
||||
// endpoint. Spec:
|
||||
// - request body {"input": <AllowInput>}
|
||||
// - response body {"result": true|false}
|
||||
// - 5-second per-request timeout
|
||||
// - non-2xx, transport error, missing/malformed result → policy
|
||||
// decision is "deny" unless FailOpen=true
|
||||
//
|
||||
// The path "/v1/data/zddc/access/allow" is the OPA convention; the
|
||||
// "zddc.access" Rego package on an external server would expose
|
||||
// `allow` for this endpoint.
|
||||
type HTTPDecider struct {
|
||||
endpoint string
|
||||
client *http.Client
|
||||
failOpen bool
|
||||
}
|
||||
|
||||
func newHTTPDecider(endpoint string, failOpen bool, transport http.RoundTripper) (*HTTPDecider, error) {
|
||||
if _, err := url.Parse(endpoint); err != nil {
|
||||
return nil, fmt.Errorf("invalid OPA URL %q: %w", endpoint, err)
|
||||
}
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
if transport != nil {
|
||||
c.Transport = transport
|
||||
}
|
||||
return &HTTPDecider{
|
||||
endpoint: strings.TrimRight(endpoint, "/") + "/v1/data/zddc/access/allow",
|
||||
client: c,
|
||||
failOpen: failOpen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type opaResponse struct {
|
||||
Result *bool `json:"result"`
|
||||
}
|
||||
|
||||
func (d *HTTPDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
|
||||
body, err := json.Marshal(struct {
|
||||
Input AllowInput `json:"input"`
|
||||
}{Input: input})
|
||||
if err != nil {
|
||||
return d.failResult(fmt.Errorf("marshal input: %w", err))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return d.failResult(fmt.Errorf("build request: %w", err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return d.failResult(fmt.Errorf("opa request: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Read up to 512 bytes of the error body for the log without
|
||||
// blowing up on a verbose OPA error page.
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return d.failResult(fmt.Errorf("opa returned %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))))
|
||||
}
|
||||
var parsed opaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return d.failResult(fmt.Errorf("decode opa response: %w", err))
|
||||
}
|
||||
if parsed.Result == nil {
|
||||
return d.failResult(errors.New("opa response missing 'result' field"))
|
||||
}
|
||||
return *parsed.Result, nil
|
||||
}
|
||||
|
||||
// failResult logs the failure and returns the configured fail-mode
|
||||
// decision. Logged at WARN so a healthy run is silent but a sick OPA
|
||||
// is loud.
|
||||
func (d *HTTPDecider) failResult(err error) (bool, error) {
|
||||
if d.failOpen {
|
||||
slog.Warn("policy decision failed; failing open (allow)", "endpoint", d.endpoint, "err", err)
|
||||
return true, nil
|
||||
}
|
||||
slog.Warn("policy decision failed; failing closed (deny)", "endpoint", d.endpoint, "err", err)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AllowFromChain is a convenience for callers that already have a
|
||||
// PolicyChain in hand. Equivalent to constructing AllowInput manually
|
||||
// from (chain, email, path) and calling d.Allow.
|
||||
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
|
||||
in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)}
|
||||
in.User.Email = email
|
||||
return d.Allow(ctx, in)
|
||||
}
|
||||
|
||||
// cachingDecider wraps another Decider with a small per-decision cache.
|
||||
// Designed for the external-OPA hot path: a single .archive listing or
|
||||
// directory enumeration can hit the same (email, dir-policy) tuple
|
||||
// dozens of times in milliseconds, and a remote OPA round-trip per
|
||||
// query would dominate latency. The 1s default TTL bounds staleness to
|
||||
// the same window as the fsnotify watcher's debounce, so a `.zddc` edit
|
||||
// is reflected in the next listing rather than carried over indefinitely.
|
||||
//
|
||||
// Key shape: SHA-256 of the canonical JSON-serialized AllowInput. This
|
||||
// makes the cache safe across all input variations (different paths,
|
||||
// different chains, different users) without us having to enumerate
|
||||
// the dimensions.
|
||||
type cachingDecider struct {
|
||||
inner Decider
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
expires time.Time
|
||||
allow bool
|
||||
}
|
||||
|
||||
func (d *cachingDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
|
||||
key, err := cacheKey(input)
|
||||
if err != nil {
|
||||
// Couldn't key — fall through to inner without caching. Should
|
||||
// never happen in practice; AllowInput marshals as plain JSON.
|
||||
return d.inner.Allow(ctx, input)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
d.mu.Lock()
|
||||
if d.entries == nil {
|
||||
d.entries = make(map[string]cacheEntry)
|
||||
}
|
||||
if e, ok := d.entries[key]; ok && now.Before(e.expires) {
|
||||
d.mu.Unlock()
|
||||
return e.allow, nil
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
allow, err := d.inner.Allow(ctx, input)
|
||||
if err != nil {
|
||||
return allow, err
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
// Best-effort eviction of expired entries — keeps the map from
|
||||
// growing unbounded under high cardinality. O(n) but capped to
|
||||
// occasional sweeps; fine for this scale.
|
||||
if len(d.entries) > 4096 {
|
||||
for k, e := range d.entries {
|
||||
if now.After(e.expires) {
|
||||
delete(d.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
d.entries[key] = cacheEntry{expires: now.Add(d.ttl), allow: allow}
|
||||
d.mu.Unlock()
|
||||
return allow, nil
|
||||
}
|
||||
|
||||
func cacheKey(input AllowInput) (string, error) {
|
||||
b, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:]), nil
|
||||
}
|
||||
403
zddc/internal/policy/policy_test.go
Normal file
403
zddc/internal/policy/policy_test.go
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestNew_ModeSelection: New() picks the right implementation per URL.
|
||||
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
|
||||
// by default; CacheTTL<0 disables the wrapper.
|
||||
func TestNew_ModeSelection(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
ttl time.Duration
|
||||
wantType string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", 0, "*policy.InternalDecider", false},
|
||||
{"internal", 0, "*policy.InternalDecider", false},
|
||||
{"INTERNAL", 0, "*policy.InternalDecider", false},
|
||||
{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
|
||||
{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
|
||||
{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
|
||||
{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
|
||||
{"ftp://nope", 0, "", true},
|
||||
{"garbage", 0, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.url, func(t *testing.T) {
|
||||
d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("New() = nil error, want error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("New() unexpected error: %v", err)
|
||||
}
|
||||
got := describe(d)
|
||||
if got != tc.wantType {
|
||||
t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func describe(v interface{}) string {
|
||||
switch v.(type) {
|
||||
case *InternalDecider:
|
||||
return "*policy.InternalDecider"
|
||||
case *HTTPDecider:
|
||||
return "*policy.HTTPDecider"
|
||||
case *cachingDecider:
|
||||
return "*policy.cachingDecider"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalDecider_ParityWithAllowedWithChain: the internal
|
||||
// decider returns the same answer as zddc.AllowedWithChain for
|
||||
// every documented cascade scenario.
|
||||
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
|
||||
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
|
||||
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
|
||||
allowDeny := func(a, d []string) zddc.ZddcFile {
|
||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
|
||||
}
|
||||
empty := zddc.ZddcFile{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain zddc.PolicyChain
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"empty chain, no files → allow",
|
||||
zddc.PolicyChain{HasAnyFile: false},
|
||||
"alice@example.com",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"files exist but no rule matches → deny",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"leaf allow wins",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"leaf deny beats parent allow (bottom-up first match)",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
allow("*@example.com"),
|
||||
deny("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"leaf has no rule for user, falls back to parent allow",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
allow("*@example.com"),
|
||||
allow("bob@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"leaf allows user that parent denies",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
deny("alice@example.com"),
|
||||
allow("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"multi-level: deepest match wins",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
allow("*@example.com"),
|
||||
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
|
||||
allow("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
},
|
||||
}
|
||||
d := &InternalDecider{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("AllowFromChain: %v", err)
|
||||
}
|
||||
want := zddc.AllowedWithChain(tc.chain, tc.email)
|
||||
if got != want {
|
||||
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("decider = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
|
||||
// OPA shape and acts on a 200 with {"result": true|false}.
|
||||
func TestHTTPDecider_HappyPath(t *testing.T) {
|
||||
var captured struct {
|
||||
path string
|
||||
contentType string
|
||||
body []byte
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured.path = r.URL.Path
|
||||
captured.contentType = r.Header.Get("Content-Type")
|
||||
captured.body, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
in := AllowInput{Path: "/p"}
|
||||
in.User.Email = "alice@example.com"
|
||||
in.PolicyChain = &SerializableChain{HasAnyFile: true}
|
||||
|
||||
got, err := d.Allow(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if got != true {
|
||||
t.Errorf("Allow = false, want true")
|
||||
}
|
||||
if captured.path != "/v1/data/zddc/access/allow" {
|
||||
t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
|
||||
}
|
||||
if !strings.HasPrefix(captured.contentType, "application/json") {
|
||||
t.Errorf("content-type = %q, want application/json", captured.contentType)
|
||||
}
|
||||
// Body should be {"input": {...}}
|
||||
var wrap struct{ Input AllowInput }
|
||||
if err := json.Unmarshal(captured.body, &wrap); err != nil {
|
||||
t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
|
||||
}
|
||||
if wrap.Input.User.Email != "alice@example.com" {
|
||||
t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
|
||||
// returns deny (false) by default, with no Go error returned to
|
||||
// the caller (handlers don't have to special-case it).
|
||||
func TestHTTPDecider_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if got != false {
|
||||
t.Errorf("on 500, Allow = true, want false (fail-closed default)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
|
||||
// returns allow.
|
||||
func TestHTTPDecider_FailOpen(t *testing.T) {
|
||||
d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if got != true {
|
||||
t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
|
||||
// the default 1s cache only round-trips once for a burst of identical
|
||||
// queries. Verifies the listing-amortization benefit for external mode.
|
||||
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(`{"result": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
in := AllowInput{Path: "/p"}
|
||||
in.User.Email = "alice@example.com"
|
||||
|
||||
// 50 identical calls → exactly 1 round-trip thanks to the cache.
|
||||
for i := 0; i < 50; i++ {
|
||||
got, err := d.Allow(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow #%d: %v", i, err)
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("Allow #%d = false, want true", i)
|
||||
}
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
|
||||
// path produces a separate cache entry; a different-decision answer is
|
||||
// not masked by the cached one.
|
||||
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var wrap struct {
|
||||
Input AllowInput `json:"input"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&wrap)
|
||||
// Allow only "alice"; deny everyone else.
|
||||
allow := wrap.Input.User.Email == "alice@example.com"
|
||||
resp, _ := json.Marshal(map[string]bool{"result": allow})
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"alice@example.com", true},
|
||||
{"bob@example.com", false},
|
||||
{"alice@example.com", true}, // cached
|
||||
{"bob@example.com", false}, // cached
|
||||
{"carol@example.com", false}, // new
|
||||
} {
|
||||
in := AllowInput{Path: "/p"}
|
||||
in.User.Email = tc.email
|
||||
got, err := d.Allow(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow(%s): %v", tc.email, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
|
||||
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
|
||||
func TestCachingDecider_TTLExpires(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(`{"result": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
in := AllowInput{Path: "/p"}
|
||||
in.User.Email = "alice@example.com"
|
||||
|
||||
if _, err := d.Allow(context.Background(), in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Allow(context.Background(), in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two calls so far; the second is a cache hit. hits==1.
|
||||
if hits != 1 {
|
||||
t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
|
||||
}
|
||||
// Wait past the TTL.
|
||||
time.Sleep(20 * 1000000) // 20ms
|
||||
if _, err := d.Allow(context.Background(), in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 2 {
|
||||
t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
|
||||
// inner decider unwrapped, useful for tests that want predictable
|
||||
// per-call HTTP traffic.
|
||||
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(`{"result": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL, CacheTTL: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
in := AllowInput{Path: "/p"}
|
||||
in.User.Email = "alice@example.com"
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = d.Allow(context.Background(), in)
|
||||
}
|
||||
if hits != 5 {
|
||||
t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
|
||||
// result field also fails closed.
|
||||
func TestHTTPDecider_MalformedResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := New(Config{URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if got != false {
|
||||
t.Errorf("on missing result, Allow = true, want false (fail-closed)")
|
||||
}
|
||||
}
|
||||
26
zddc/internal/policy/rego.go
Normal file
26
zddc/internal/policy/rego.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package policy
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// ReferenceRego is the canonical Rego policy bundled with zddc-server.
|
||||
// It mirrors the InternalDecider's semantics exactly — every release CI
|
||||
// run validates parity via parity_test.go (which imports the OPA library
|
||||
// as a test-only dependency, so the production binary stays OPA-free).
|
||||
//
|
||||
// Operators running an external OPA can use this as the starting point
|
||||
// for their own policy bundle:
|
||||
//
|
||||
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
||||
//
|
||||
// Customizations typical for federal deployments:
|
||||
//
|
||||
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
||||
// are absolute (NIST AC-6 least-privilege posture).
|
||||
// - Add role-based access via additional input fields (input.user.roles
|
||||
// populated by the upstream proxy from SAML/OIDC claims).
|
||||
// - Add time-of-day or IP-range constraints.
|
||||
// - Emit decision logs in a SIEM-friendly format via OPA's logging
|
||||
// plugins.
|
||||
//
|
||||
//go:embed rego/access.rego
|
||||
var ReferenceRego string
|
||||
119
zddc/internal/policy/rego/access.rego
Normal file
119
zddc/internal/policy/rego/access.rego
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Reference Rego policy that mirrors zddc-server's built-in `internal`
|
||||
# decider exactly. Federal customers running their own OPA can use this
|
||||
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
|
||||
# parent-deny rule for NIST AC-6 compliance).
|
||||
#
|
||||
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
|
||||
# truth for production. This file is validated against that evaluator on
|
||||
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
|
||||
# Both implementations must produce the same decision for every fixture.
|
||||
#
|
||||
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
|
||||
# {
|
||||
# "user": {"email": "alice@example.com"},
|
||||
# "path": "/Project-A/sub/",
|
||||
# "policy_chain": {
|
||||
# "levels": [
|
||||
# {"acl": {}, "admins": ["admin@example.com"]},
|
||||
# {"acl": {"allow": ["*@example.com"]}}
|
||||
# ],
|
||||
# "has_any_file": true
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
|
||||
# bottom-up (deepest first); first explicit match wins; within a single
|
||||
# level, a deny pattern is checked before an allow pattern.
|
||||
#
|
||||
# Default-allow when has_any_file is false (no .zddc anywhere → public);
|
||||
# default-deny when has_any_file is true and nothing matched (the safety
|
||||
# net the file at <ZDDC_ROOT>/.zddc enables).
|
||||
|
||||
package zddc.access
|
||||
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
|
||||
default allow := false
|
||||
|
||||
# Allow when no .zddc files anywhere in the chain AND no rule matches.
|
||||
allow if {
|
||||
not input.policy_chain.has_any_file
|
||||
count(matched_levels) == 0
|
||||
}
|
||||
|
||||
# Allow when the deepest matching level grants.
|
||||
allow if {
|
||||
count(matched_levels) > 0
|
||||
deepest := max(matched_levels)
|
||||
level_grants(input.policy_chain.levels[deepest])
|
||||
}
|
||||
|
||||
# Set of level indices where the email matches at least one allow or deny
|
||||
# pattern. The deepest-index member is the level whose decision counts.
|
||||
matched_levels := {i |
|
||||
some i
|
||||
level_matches(input.policy_chain.levels[i])
|
||||
}
|
||||
|
||||
# A level "matches" if its email is in either its deny list or its allow
|
||||
# list. Whether the level grants or denies is a separate question
|
||||
# (level_grants below) — deny is checked before allow within a level.
|
||||
level_matches(level) if {
|
||||
some pattern in level.acl.deny
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
level_matches(level) if {
|
||||
some pattern in level.acl.allow
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
# A level grants iff (a) no deny pattern matches at this level AND (b) some
|
||||
# allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked
|
||||
# first; if no deny hit, an allow match returns true.
|
||||
level_grants(level) if {
|
||||
not level_denies(level)
|
||||
some pattern in level.acl.allow
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
level_denies(level) if {
|
||||
some pattern in level.acl.deny
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
# email_matches: glob-match a pattern against an email, with the @-boundary
|
||||
# rule from acl.go's MatchesPattern: * does not cross @. Four cases:
|
||||
#
|
||||
# 1. exact match (covers patterns with no wildcard)
|
||||
# 2. bare "*" matches any non-empty email (special case because OPA's
|
||||
# glob.match treats empty delimiters [] inconsistently for the
|
||||
# lone-* pattern)
|
||||
# 3. pattern has both * and @: standard glob with @ as a delimiter so
|
||||
# `*@example.com` matches alice@example.com but `*example.com`
|
||||
# does NOT match anything (* won't cross @)
|
||||
# 4. pattern has * but no @: glob against the full email with no
|
||||
# delimiter (so `alice*` matches alice@anything)
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
pattern == email
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
pattern == "*"
|
||||
email != ""
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
contains(pattern, "*")
|
||||
contains(pattern, "@")
|
||||
glob.match(pattern, ["@"], email)
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
contains(pattern, "*")
|
||||
not contains(pattern, "@")
|
||||
pattern != "*"
|
||||
glob.match(pattern, [], email)
|
||||
}
|
||||
|
|
@ -7,9 +7,13 @@ import (
|
|||
)
|
||||
|
||||
// ACLRules holds email allow/deny lists.
|
||||
//
|
||||
// JSON tags are present so this type round-trips cleanly when included
|
||||
// in the external-OPA input body (see internal/policy). The canonical
|
||||
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
||||
type ACLRules struct {
|
||||
Allow []string `yaml:"allow"`
|
||||
Deny []string `yaml:"deny"`
|
||||
Allow []string `yaml:"allow" json:"allow,omitempty"`
|
||||
Deny []string `yaml:"deny" json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
||||
|
|
@ -36,10 +40,10 @@ type ACLRules struct {
|
|||
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
|
||||
// and never re-validates — operators delete the file to force a refetch.
|
||||
type ZddcFile struct {
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Title string `yaml:"title"`
|
||||
Apps map[string]string `yaml:"apps,omitempty"`
|
||||
ACL ACLRules `yaml:"acl" json:"acl"`
|
||||
Admins []string `yaml:"admins" json:"admins,omitempty"`
|
||||
Title string `yaml:"title" json:"title,omitempty"`
|
||||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a .zddc YAML file.
|
||||
|
|
|
|||
Loading…
Reference in a new issue