feat(server): self-issued bearer tokens + --no-auth flag

zddc-server now issues its own bearer tokens for non-browser callers
(CLI tools, scripts, downstream proxy/cache/mirror instances). No
external IDP, no JWKS rotation. Self-service flow: sign in via the
browser, visit /.tokens, click "Create token," paste the resulting
plaintext into a 0600 file, and pass --bearer-file <path> to whatever
calls back into the server.

Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token
with email/created/expires/description. Filename is the *hash* of the
plaintext, never the plaintext itself — a leak of the tokens
directory exposes hashes, not credentials. Mode 0600 / 0700, atomic
writes via temp+rename. Already shielded from public serving by the
existing dot-prefix guards in dispatch and fs.ListDirectory.

ACLMiddleware now recognises Authorization: Bearer <token>. On valid
token, sets the request email from the token file and falls through
to the existing ACL chain. On any failure (unknown / expired / store
unavailable / Bearer with no validator), returns 401 — no silent
fallback to anonymous, so a misconfigured client fails loudly.

JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke)
backs a small inline HTML self-service page at /.tokens. Users can
only see and revoke their own tokens; cross-user revoke returns 404
to avoid leaking ownership.

--no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this
instance. On master: anyone reads everything (dev / trusted-LAN /
public-read deployments). On a downstream proxy/cache/mirror: trust
upstream's filtering, don't re-evaluate ACLs locally. Implemented as
a swap to policy.AllowAllDecider; all existing handlers keep calling
AllowFromChain unchanged. Distinct from --insecure, which only
relaxes the no-root-.zddc startup check. WARN-level startup log when
--no-auth is active so accidental enablement is visible.

33 new tests covering token storage, validation/expiry/revocation,
the JSON API end-to-end, the HTML page, and the middleware-Bearer
integration including the case-insensitive prefix and expired-token
paths. Full suite + go vet clean.

Doc updates: zddc/README.md "Authentication" rewritten to cover both
auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a
"Bearer tokens" subsection flagging the dot-prefix-shielding pre-
condition; ARCHITECTURE.md adds "Bearer token issuance" and
"--no-auth" subsections under "Server security model" with the
hash-as-filename rationale and dispatch-shielding regression-
sensitivity called out; CLAUDE.md adds a one-line summary of the new
auth topology so future agents pick it up by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-08 07:40:28 -05:00
parent 562b105550
commit 97ffaac13b
14 changed files with 1733 additions and 40 deletions

View file

@ -446,12 +446,30 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity | | `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
| `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_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_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_NO_AUTH` | *(empty)* | `1` skips ACL enforcement entirely on this instance. On a master: anyone reads everything (dev / trusted-LAN read-only deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. **Distinct from `ZDDC_INSECURE`** (which gates a startup safety check). |
| `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_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_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_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_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). | | `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. | | `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### Bearer tokens (CLI auth)
zddc-server self-issues bearer tokens for CLI / non-browser callers. No external IDP, no JWKS rotation. Source of truth: `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` — a YAML file per token with `email`, `created`, `expires`, `description`. Filename is the **hash** of the token; the plaintext is never persisted.
User flow: sign in to the master in a browser, visit `/.tokens`, click "Create token," copy the value (shown once). Store in a 0600 file and pass `--bearer-file <path>` to a CLI that calls back into zddc-server, or send `Authorization: Bearer <token>` directly from scripts.
Endpoints:
- `GET /.tokens` — HTML self-service page (gated by browser auth).
- `GET/POST /.api/tokens` — list / create. Plaintext returned **only** on POST response.
- `DELETE /.api/tokens/<id>` — revoke. `<id>` is the 8-char short ID or full hash.
Validation flow inside the request path: `ACLMiddleware` checks for `Authorization: Bearer …` first; on success, sets the request email from the token file. On any failure (unknown / expired / store unavailable), returns 401 — there is no fallback to header-based auth, so a misconfigured client can't silently masquerade as anonymous. If no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segments 404 from direct GETs, and `fs.ListDirectory` filters them from listings. **Verify on any new deployment by attempting `GET /.zddc.d/tokens/anything` and confirming 404.**
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Release tagging ### Release tagging
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags. zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags.

View file

@ -459,7 +459,7 @@ none of them is load-bearing alone.
| Layer | Job | Implementation | | Layer | Job | Implementation |
|---|---|---| |---|---|---|
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate | | Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| 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/` | | 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 with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`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 | | ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`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 |
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` | | Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
@ -468,6 +468,44 @@ none of them is load-bearing alone.
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records | | Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records |
| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically | | File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically |
### Bearer token issuance
zddc-server issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). The master is the identity provider; no external IDP, no JWKS rotation.
**Storage** — `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` per token. Filename is the **hash** of the token, never the plaintext value. File contents are YAML (`email`, `created`, `expires`, `description`). Mode 0600, directory mode 0700, atomic writes via temp+rename.
**Why hash-as-filename**: a leak of the tokens directory (backup tools, FS-level audit logs, accidental `ls` in a screen recording) exposes hashes, not credentials. Same posture as `/etc/shadow` storing password hashes rather than passwords. The plaintext exists only in transit (HTTP `Authorization` header) and on the operator's disk (a 0600 file they manage).
**Self-service flow**:
1. User signs in via the browser (master's normal upstream auth).
2. Visits `/.tokens` — small HTML page (`zddc/internal/handler/tokenhandler.go`) listing existing tokens and offering a creation form.
3. JS fetches the JSON API (`/.api/tokens`), POSTs a new token, displays the plaintext **once**.
4. User copies into a 0600 file; passes `--bearer-file <path>` to a CLI.
**API**:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (no plaintext) |
| `POST` | `/.api/tokens` | create; plaintext returned exactly once |
| `DELETE` | `/.api/tokens/<id>` | revoke (8-char ID or full 64-char hash) |
**Validation in the request path**: `ACLMiddleware` in `zddc/internal/handler/middleware.go` checks `Authorization: Bearer …` first; on success, sets the request email from the token file and falls through. Any failure (missing / malformed / expired) → `401`. There is no silent fallback to anonymous on Bearer failure — a misconfigured client must fail loudly rather than escalate to "no auth at all." When no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
**Directory shielding**: the tokens path is shielded by the existing `.`-prefix rules — `dispatch()` 404s any URL containing a dot-prefixed segment (other than the recognized virtual prefixes), and `fs.ListDirectory` filters dot entries from listings. The token system relies on this; a regression here is a credentials-leak vector. The token-handler test suite (`tokenhandler_test.go`) exercises the auth path; verifying the URL-level guard is the responsibility of `main_test.go` (`TestDispatchHidesDotPrefixedSegments`).
### `--no-auth` / "this instance is not the ACL boundary"
A symmetric flag, used in two distinct deployment shapes:
- **Master with `--no-auth`**: no ACL enforcement, no auth required. Anyone hitting the port reads everything in scope. Suitable for dev, internal trusted-LAN read-only tooling, or genuinely public archives.
- **Client with `--no-auth`** (downstream proxy/cache/mirror — see "Master + proxy / cache / mirror" below for context): the client trusts upstream's ACL filtering. Whatever the upstream returned is what the client serves; no per-request re-evaluation against `.zddc` files in the cache directory. Single-user-trust model on a laptop.
Implementation is a single swap: `policy.AllowAllDecider{}` replaces the configured decider when `cfg.NoAuth` is true. All existing handlers continue to call `policy.AllowFromChain` (or equivalent) unchanged; they just always get `allowed=true`. Logged at `WARN` on every restart so operators who set the flag inadvertently see it on stderr.
Distinct from `--insecure`, which only relaxes a startup-time safety check (refuse to start when no root `.zddc` exists). The two flags are independent.
### Commercial vs federal trust model ### Commercial vs federal trust model
The current implementation is well-shaped for a commercial-tenant model with The current implementation is well-shaped for a commercial-tenant model with

View file

@ -22,7 +22,7 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application: This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/` — six self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). The sixth tool, `form/`, is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); see AGENTS.md "Form-data system" and ARCHITECTURE.md "Form Renderer". - `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/` — six self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). The sixth tool, `form/`, is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); see AGENTS.md "Form-data system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time. - `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Two auth paths: (a) `Authorization: Bearer <token>` validated against self-issued tokens stored under `<ZDDC_ROOT>/.zddc.d/tokens/` (filename = SHA256 of token), used by CLI / non-browser callers; (b) `X-Auth-Request-Email` injected by an upstream auth proxy, used for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. `--no-auth` skips ACL enforcement entirely (distinct from the older `--insecure` which only relaxes the no-root-`.zddc` startup check). Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). - `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all six tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override. - **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all six tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo. - `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo.

View file

@ -133,16 +133,74 @@ ZDDC_TLS_KEY=/etc/ssl/zddc/server.key \
## Authentication ## Authentication
zddc-server does **not** perform authentication itself. It reads the user's email address zddc-server reads the user's email from one of two sources, in order:
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 request is 1. **`Authorization: Bearer <token>`** — a server-issued bearer token (see
allowed only if (a) **no `.zddc` file exists anywhere in the chain from `ZDDC_ROOT` to "Bearer tokens" below). Used by CLI clients, scripts, and any
the requested directory** (a fresh tree with zero `.zddc` files defaults to public non-browser caller. Validated against `<ZDDC_ROOT>/.zddc.d/tokens/`;
access — see warning at the top of the next section), or (b) some level in the chain on success the request runs as the email recorded in the token file.
explicitly allows the caller's email. See "Access control: the `.zddc` cascade" below On any failure (unknown token, expired token, malformed header) the
for the full evaluation order. request is rejected with `401` — there is no silent fallback.
2. **`X-Auth-Request-Email`** (header name configurable) — set by an
upstream reverse proxy (nginx, Caddy, Traefik, oauth2-proxy, Azure
Application Gateway, etc.) after authenticating a browser user.
This is the path browser sessions use. **The header is trusted from
any client when no auth proxy is in front** — bind to loopback or
require TLS + proxy in any deployment that exposes the port.
If neither source yields an email, the user is treated as anonymous
(empty email). A request is then 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 empty email / anonymous callers.
Pass `--no-auth` (or `ZDDC_NO_AUTH=1`) to skip ACL enforcement entirely
on this instance. Anyone hitting the port reads everything in scope, no
matter what `.zddc` files say. Distinct from `--insecure`, which only
permits startup with no root `.zddc`. Use cases: dev, trusted-LAN read-
only deployments, and the "client trusts upstream's filtering" mode of
zddc-server when running as a downstream cache or mirror (see the
"master + proxy/cache/mirror" architecture in ARCHITECTURE.md).
### Bearer tokens
Bearer tokens are issued by zddc-server itself — no external IDP, no
JWKS rotation. Users self-serve via the browser:
1. Sign in to the master via the browser as usual (whatever the upstream
auth proxy expects).
2. Visit `https://<master>/.tokens`. The page lists existing tokens and
offers a "Create token" form (free-form description, optional
expiry).
3. The newly-created token is shown **once** — copy it into a file with
mode 0600 (e.g. `~/.config/zddc/token`). It is never re-derivable
from the server's storage.
4. Pass the token to a CLI/client: `--bearer-file ~/.config/zddc/token`
(or send `Authorization: Bearer <token>` directly via curl/scripts).
Storage lives at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex-of-token>`,
file mode 0600, directory mode 0700. The file is YAML with `email`,
`created`, `expires`, and `description` fields. The plaintext token is
never stored at rest — only its SHA256 hash. A token leak via backup
tools, FS-level audit logs, or an `ls` of the directory would expose
hashes, not credentials.
The directory is shielded from public serving by the standard
dot-prefix guard (any `.foo` segment in a request URL returns 404, and
listings filter dot-prefix entries) — but verify your deployment by
attempting `GET /.zddc.d/tokens/<anything>` and confirming 404.
JSON API for automation (same auth as the page):
| Method | Path | Description |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (metadata only — never the plaintext) |
| `POST` | `/.api/tokens` | create a token. Body: `{"description":"…","expires":"<RFC3339>"}` (both optional). Returns the plaintext **once**. |
| `DELETE` | `/.api/tokens/<id>` | revoke. `<id>` is the 8-char ID shown in `GET` (or the full 64-char hash). |
A user can only see and revoke their own tokens. Revoking another
user's token returns 404 to avoid leaking ownership.
## Access control: the `.zddc` cascade ## Access control: the `.zddc` cascade

View file

@ -16,6 +16,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
@ -161,15 +162,35 @@ func main() {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err) slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1) os.Exit(1)
} }
// --no-auth swaps the configured decider for one that allows
// everything. Logged at warn level so an operator who set this
// inadvertently sees it on every restart.
if cfg.NoAuth {
decider = policy.AllowAllDecider{}
slog.Warn("--no-auth enabled: ACL enforcement is disabled. Every request is permitted regardless of .zddc rules.")
}
slog.Info("policy decider ready", slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL), "mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL, "url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL, "cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode) "cascade_mode", cfg.CascadeMode,
"no_auth", cfg.NoAuth)
// Token store: bearer-token issuance and validation.
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
// from public listings (fs.ListDirectory dot-prefix filter) and
// direct serving (dispatch's reserved-prefix guard). Failures here
// are non-fatal: token-based auth is opt-in per request, and
// header-based auth keeps working without it.
tokens, err := auth.NewStore(cfg.Root)
if err != nil {
slog.Warn("could not initialise token store; bearer-token auth disabled", "err", err)
tokens = nil
}
// Innermost handler: dispatch. // Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r) dispatch(cfg, idx, logRing, appsServer, tokens, w, r)
}) })
inner = handler.CORSMiddleware(cfg, inner) inner = handler.CORSMiddleware(cfg, inner)
// HSTS only when zddc-server itself is the TLS-terminating endpoint. // HSTS only when zddc-server itself is the TLS-terminating endpoint.
@ -179,7 +200,7 @@ func main() {
inner = handler.HSTSMiddleware(inner) inner = handler.HSTSMiddleware(inner)
} }
inner = handler.AccessLogMiddleware(auditLogger, inner) inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, inner) inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
mux.Handle("/", inner) mux.Handle("/", inner)
gzWrapper, err := newGzipWrapper() gzWrapper, err := newGzipWrapper()
@ -412,7 +433,7 @@ func embeddedVersionsForLog(embedded map[string]string) string {
} }
// dispatch routes a request to the appropriate handler. // dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) { func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path urlPath := r.URL.Path
email := handler.EmailFromContext(r) email := handler.EmailFromContext(r)
@ -425,6 +446,20 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// Token self-service: HTML page at /.tokens, JSON API at
// /.api/tokens. Both routes require an authenticated user (the
// existing email middleware injects the email from upstream auth).
// Both routes refuse to serve when no token store is available
// (e.g. NewStore failed at startup) — handled inside the handlers.
if urlPath == handler.TokensPathPrefix || urlPath == handler.TokensPathPrefix+"/" {
handler.ServeTokensPage(cfg, tokens, w, r)
return
}
if urlPath == handler.TokensAPIPathPrefix || strings.HasPrefix(urlPath, handler.TokensAPIPathPrefix+"/") {
handler.ServeTokensAPI(cfg, tokens, w, r)
return
}
// Auth check endpoints — machine-only forward_auth targets used by // Auth check endpoints — machine-only forward_auth targets used by
// upstream proxies (e.g. the dev-shell pod's Caddy in front of // upstream proxies (e.g. the dev-shell pod's Caddy in front of
// code-server) to gate routes on root-admin status. Handled before // code-server) to gate routes on root-admin status. Handled before

View file

@ -81,7 +81,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil) req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Errorf("path=%q status=%d want=%d body=%q", t.Errorf("path=%q status=%d want=%d body=%q",
tc.path, rec.Code, tc.wantStatus, rec.Body.String()) tc.path, rec.Code, tc.wantStatus, rec.Body.String())
@ -168,7 +168,7 @@ func TestDispatchAppsResolution(t *testing.T) {
// GET /archive.html → fetched from upstream (archive is available everywhere) // GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil) req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, rec, req) dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
} }
@ -178,21 +178,21 @@ func TestDispatchAppsResolution(t *testing.T) {
// GET /archive.html again → cache hit (no new upstream fetch) // GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK { if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code) t.Errorf("second /archive.html: status=%d", rec2.Code)
} }
// GET / → landing // GET / → landing
rec3 := httptest.NewRecorder() rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec3, httptest.NewRequest(http.MethodGet, "/", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK { if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code) t.Errorf("GET /: status=%d", rec3.Code)
} }
// Direct URL access to /_app/ → 404 // Direct URL access to /_app/ → 404
rec4 := httptest.NewRecorder() rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
if rec4.Code != http.StatusNotFound { if rec4.Code != http.StatusNotFound {
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code) t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
} }
@ -201,12 +201,12 @@ func TestDispatchAppsResolution(t *testing.T) {
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in // (root has no Incoming/Working/Staging ancestor), but SHOULD work in
// /Project-A/Working/. // /Project-A/Working/.
rec5 := httptest.NewRecorder() rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound { if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code) t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
} }
rec6 := httptest.NewRecorder() rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
if rec6.Code != http.StatusOK { if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code) t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
} }
@ -246,7 +246,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
body := []byte("note body") body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com") req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
} }
@ -254,7 +254,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// GET it back. // GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) { if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String()) t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
} }
@ -264,7 +264,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
req.Header.Set("X-ZDDC-Op", "move") req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md") req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
} }
@ -272,7 +272,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// DELETE it. // DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent { if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
} }
@ -280,7 +280,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// Reserved segment guard still applies to writes. // Reserved segment guard still applies to writes.
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code) t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
} }
@ -361,7 +361,7 @@ func TestDispatchArchiveRedirect(t *testing.T) {
} }
req := httptest.NewRequest(http.MethodGet, rawURL, nil) req := httptest.NewRequest(http.MethodGet, rawURL, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String()) t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
} }
@ -448,7 +448,7 @@ func TestDispatchSlashRouting(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil) req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec, req) dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String()) tc.path, rec.Code, tc.wantStatus, rec.Body.String())
@ -498,7 +498,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) {
req := httptest.NewRequest(method, path, strings.NewReader("body")) req := httptest.NewRequest(method, path, strings.NewReader("body"))
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com")) req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed { if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String()) t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
} }
@ -674,7 +674,7 @@ func TestDispatchZddcEditorAtPath(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil) req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email)) req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String()) tc.path, rec.Code, tc.wantStatus, rec.Body.String())

349
zddc/internal/auth/token.go Normal file
View file

@ -0,0 +1,349 @@
// Package auth provides bearer-token issuance and validation for
// zddc-server. Tokens are persisted as YAML files under
// <ZDDC_ROOT>/.zddc.d/tokens/, keyed by the SHA256 hash of the token
// value (filename = hash, never the plaintext token). The directory is
// shielded from public serving by the standard dot-prefix guard in the
// dispatcher and the listing filter in fs.ListDirectory.
//
// Format of a token file:
//
// email: user@example.com
// created: 2026-05-08T10:23:00Z
// expires: 2026-08-08T10:23:00Z # zero/missing = no expiry
// description: Field laptop # free-form, optional
//
// Token validation: client sends `Authorization: Bearer <token>`,
// server hashes, looks up <dir>/<sha256-hex>. If the file exists, parses
// cleanly, and is not expired, the token is valid and the request runs
// as the file's email.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// DirName is the leaf-directory name under <ZDDC_ROOT>/.zddc.d/ where
// token files live. Kept here so callers don't repeat the literal.
const DirName = "tokens"
// HashLen is the hex-encoded length of a SHA256 hash (the filename
// length for token files).
const HashLen = 64
// IDLen is the length of the short identifier used in the management UI
// and revocation API. First IDLen hex chars of the SHA256 hash.
const IDLen = 8
// ErrInvalidToken is returned when the token does not exist, fails to
// parse, or is expired. Distinct from other errors so callers can map
// it to a 401 without leaking which case occurred.
var ErrInvalidToken = errors.New("invalid token")
// ErrNotFound is returned by List/Revoke when a requested token does
// not exist. Revoke also returns this when the token exists but does
// not belong to the requesting email — same error to avoid leaking
// existence of other users' tokens.
var ErrNotFound = errors.New("token not found")
// Token holds the metadata persisted alongside a token. The token
// value itself (the bearer string the client sends) is NOT carried in
// this struct — it's only known at generation time, returned once to
// the user, and never re-derivable from the stored data.
type Token struct {
// Hash is the hex-encoded SHA256 of the plaintext token. Equal
// to the filename of the on-disk YAML file.
Hash string `yaml:"-"`
Email string `yaml:"email"`
Created time.Time `yaml:"created"`
Expires time.Time `yaml:"expires,omitempty"`
Description string `yaml:"description,omitempty"`
}
// ID returns the short identifier (first IDLen hex chars of Hash) used
// in the management UI and the revocation endpoint. Distinct from the
// secret token so it can be displayed and logged safely.
func (t Token) ID() string {
if len(t.Hash) < IDLen {
return t.Hash
}
return t.Hash[:IDLen]
}
// Expired reports whether the token has an expiry in the past. Tokens
// with a zero Expires never expire.
func (t Token) Expired(now time.Time) bool {
return !t.Expires.IsZero() && !now.Before(t.Expires)
}
// Store manages the on-disk token directory.
type Store struct {
dir string
mu sync.Mutex // serializes file creation; reads are lock-free
}
// NewStore opens (and creates if missing) the token directory at
// <root>/.zddc.d/tokens. Directory mode 0700 — single-user-trust at
// the FS layer.
func NewStore(root string) (*Store, error) {
dir := filepath.Join(root, ".zddc.d", DirName)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create token dir: %w", err)
}
// Best-effort tighten if pre-existing dir was looser. Don't fail
// startup if chmod isn't permitted (NFS/SMB shares often refuse).
_ = os.Chmod(dir, 0o700)
return &Store{dir: dir}, nil
}
// Dir returns the absolute path of the token directory. Exposed for
// tests and operator diagnostics; not used in the request path.
func (s *Store) Dir() string { return s.dir }
// Generate creates a new token for the given email and writes its file.
// Returns the plaintext bearer token (to be displayed once to the user)
// and the persisted Token metadata.
//
// expires==zero means no expiry. description is free-form; the caller
// is responsible for any sanitization (e.g. trimming, length-capping).
func (s *Store) Generate(email, description string, expires time.Time) (string, Token, error) {
if email == "" {
return "", Token{}, errors.New("email required")
}
// 32 bytes = 256 bits of entropy, URL-safe base64 → 43-char string.
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", Token{}, fmt.Errorf("generate token: %w", err)
}
plaintext := base64.RawURLEncoding.EncodeToString(raw[:])
sum := sha256.Sum256([]byte(plaintext))
hashHex := hex.EncodeToString(sum[:])
tok := Token{
Hash: hashHex,
Email: email,
Created: time.Now().UTC().Truncate(time.Second),
Expires: expires,
Description: description,
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.writeAtomic(hashHex, tok); err != nil {
return "", Token{}, err
}
return plaintext, tok, nil
}
// Validate looks up a plaintext bearer token. Returns the persisted
// Token on success, ErrInvalidToken if missing/malformed/expired.
//
// Constant-time comparison: not strictly required since the lookup is
// keyed by the hash (no string comparison happens), but the hex
// filename comparison via os.Stat is timing-stable enough for our
// purposes — there's no known oracle that distinguishes "file missing"
// from "file present but expired" without observing the response code.
func (s *Store) Validate(plaintext string) (*Token, error) {
if plaintext == "" {
return nil, ErrInvalidToken
}
sum := sha256.Sum256([]byte(plaintext))
hashHex := hex.EncodeToString(sum[:])
tok, err := s.readByHash(hashHex)
if err != nil {
return nil, ErrInvalidToken
}
if tok.Expired(time.Now()) {
return nil, ErrInvalidToken
}
return tok, nil
}
// List returns all tokens belonging to the given email, sorted by
// Created descending (newest first). Returns an empty slice (not nil)
// when the user has no tokens.
func (s *Store) List(email string) ([]Token, error) {
if email == "" {
return nil, errors.New("email required")
}
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return []Token{}, nil
}
return nil, err
}
out := make([]Token, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if len(name) != HashLen || !isHex(name) {
continue
}
tok, err := s.readByHash(name)
if err != nil {
continue
}
if !strings.EqualFold(tok.Email, email) {
continue
}
out = append(out, *tok)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Created.After(out[j].Created)
})
return out, nil
}
// Revoke deletes the token identified by the given short ID (first
// IDLen hex chars of the hash) or the full hash. Only revokes tokens
// belonging to email; returns ErrNotFound otherwise (whether the token
// is missing or owned by a different user — to avoid leaking ownership).
func (s *Store) Revoke(email, idOrHash string) error {
if email == "" {
return errors.New("email required")
}
idOrHash = strings.ToLower(strings.TrimSpace(idOrHash))
s.mu.Lock()
defer s.mu.Unlock()
// Full-hash path: O(1) lookup.
if len(idOrHash) == HashLen && isHex(idOrHash) {
tok, err := s.readByHash(idOrHash)
if err != nil {
return ErrNotFound
}
if !strings.EqualFold(tok.Email, email) {
return ErrNotFound
}
return os.Remove(filepath.Join(s.dir, idOrHash))
}
// Short-ID path: must scan. Token IDs are 8 hex chars (~32 bits),
// collision-resistant in practice for any reasonable per-user
// token count. We pick the (single) match owned by email.
if len(idOrHash) < 4 || !isHex(idOrHash) {
return ErrNotFound
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return ErrNotFound
}
var matches []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if len(name) != HashLen || !isHex(name) {
continue
}
if !strings.HasPrefix(name, idOrHash) {
continue
}
tok, err := s.readByHash(name)
if err != nil {
continue
}
if !strings.EqualFold(tok.Email, email) {
continue
}
matches = append(matches, name)
}
if len(matches) == 0 {
return ErrNotFound
}
if len(matches) > 1 {
return fmt.Errorf("ambiguous id %q matches %d tokens", idOrHash, len(matches))
}
return os.Remove(filepath.Join(s.dir, matches[0]))
}
// readByHash reads and parses the token file named by hashHex. The
// returned Token has its Hash field set.
func (s *Store) readByHash(hashHex string) (*Token, error) {
if len(hashHex) != HashLen || !isHex(hashHex) {
return nil, ErrInvalidToken
}
path := filepath.Join(s.dir, hashHex)
bytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tok Token
if err := yaml.Unmarshal(bytes, &tok); err != nil {
return nil, err
}
tok.Hash = hashHex
if tok.Email == "" {
return nil, ErrInvalidToken
}
return &tok, nil
}
// writeAtomic serializes a token to YAML and writes it via tmp+rename.
// File mode 0600.
func (s *Store) writeAtomic(hashHex string, tok Token) error {
bytes, err := yaml.Marshal(tok)
if err != nil {
return fmt.Errorf("marshal token: %w", err)
}
final := filepath.Join(s.dir, hashHex)
tmp, err := os.CreateTemp(s.dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(bytes); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("write temp: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("chmod temp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close temp: %w", err)
}
if err := os.Rename(tmpName, final); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename temp: %w", err)
}
return nil
}
// isHex reports whether s is a non-empty string of [0-9a-f] characters.
func isHex(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}

View file

@ -0,0 +1,325 @@
package auth
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
root := t.TempDir()
store, err := NewStore(root)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return store
}
func TestNewStoreCreatesDirectory(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
want := filepath.Join(root, ".zddc.d", DirName)
if store.Dir() != want {
t.Errorf("Dir() = %q, want %q", store.Dir(), want)
}
info, err := os.Stat(store.Dir())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Error("expected directory")
}
// On Linux, expect mode 0700; some FS (NFS, SMB) won't honor chmod
// so we accept any mode where no group/other bits are set in the
// happy-path tempdir.
mode := info.Mode().Perm()
if mode&0o077 != 0 {
t.Errorf("expected mode 0700, got %o", mode)
}
}
func TestGenerateAndValidate(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "test laptop", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if plaintext == "" {
t.Fatal("plaintext token empty")
}
if len(plaintext) != 43 {
t.Errorf("plaintext length = %d, want 43", len(plaintext))
}
if tok.Email != "alice@example.com" {
t.Errorf("Email = %q", tok.Email)
}
if tok.Description != "test laptop" {
t.Errorf("Description = %q", tok.Description)
}
if !tok.Expires.IsZero() {
t.Errorf("Expires = %v, want zero", tok.Expires)
}
if len(tok.Hash) != HashLen {
t.Errorf("Hash length = %d, want %d", len(tok.Hash), HashLen)
}
if tok.ID() != tok.Hash[:IDLen] {
t.Errorf("ID() = %q, want %q", tok.ID(), tok.Hash[:IDLen])
}
// Validate the same plaintext.
got, err := store.Validate(plaintext)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if got.Email != "alice@example.com" {
t.Errorf("validated Email = %q", got.Email)
}
if got.Hash != tok.Hash {
t.Errorf("validated Hash = %q, want %q", got.Hash, tok.Hash)
}
}
func TestValidateRejectsUnknownToken(t *testing.T) {
store := newTestStore(t)
if _, err := store.Validate("not-a-real-token"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken, got %v", err)
}
}
func TestValidateRejectsEmptyToken(t *testing.T) {
store := newTestStore(t)
if _, err := store.Validate(""); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken, got %v", err)
}
}
func TestValidateRejectsExpiredToken(t *testing.T) {
store := newTestStore(t)
plaintext, _, err := store.Generate("bob@example.com", "expired", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken for expired, got %v", err)
}
}
func TestValidateAcceptsFutureExpiry(t *testing.T) {
store := newTestStore(t)
plaintext, _, err := store.Generate("bob@example.com", "valid", time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, err := store.Validate(plaintext); err != nil {
t.Errorf("Validate of future-expiry token: %v", err)
}
}
func TestGenerateProducesUniqueTokens(t *testing.T) {
store := newTestStore(t)
seen := make(map[string]bool)
for i := 0; i < 50; i++ {
plaintext, _, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate %d: %v", i, err)
}
if seen[plaintext] {
t.Fatalf("duplicate token at iteration %d", i)
}
seen[plaintext] = true
}
}
func TestList(t *testing.T) {
store := newTestStore(t)
if list, err := store.List("alice@example.com"); err != nil || len(list) != 0 {
t.Errorf("empty List: %v / %d", err, len(list))
}
_, _, _ = store.Generate("alice@example.com", "first", time.Time{})
time.Sleep(1100 * time.Millisecond)
_, _, _ = store.Generate("alice@example.com", "second", time.Time{})
_, _, _ = store.Generate("bob@example.com", "bob's", time.Time{})
alice, err := store.List("alice@example.com")
if err != nil {
t.Fatalf("List alice: %v", err)
}
if len(alice) != 2 {
t.Fatalf("List alice = %d, want 2", len(alice))
}
// Newest-first ordering.
if alice[0].Description != "second" {
t.Errorf("alice[0].Description = %q, want \"second\"", alice[0].Description)
}
if alice[1].Description != "first" {
t.Errorf("alice[1].Description = %q, want \"first\"", alice[1].Description)
}
bob, err := store.List("bob@example.com")
if err != nil {
t.Fatalf("List bob: %v", err)
}
if len(bob) != 1 {
t.Fatalf("List bob = %d, want 1", len(bob))
}
if bob[0].Description != "bob's" {
t.Errorf("bob[0].Description = %q", bob[0].Description)
}
}
func TestListIgnoresNonTokenFiles(t *testing.T) {
store := newTestStore(t)
_, _, err := store.Generate("alice@example.com", "real", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Drop a junk file alongside.
if err := os.WriteFile(filepath.Join(store.Dir(), "garbage"), []byte("nope"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(store.Dir(), "ZZ"+strings.Repeat("0", HashLen-2)), []byte("not hex"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
list, err := store.List("alice@example.com")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Errorf("List = %d, want 1 (ignoring junk)", len(list))
}
}
func TestRevokeByShortID(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "to revoke", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := store.Revoke("alice@example.com", tok.ID()); err != nil {
t.Fatalf("Revoke: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
}
}
func TestRevokeByFullHash(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := store.Revoke("alice@example.com", tok.Hash); err != nil {
t.Fatalf("Revoke by hash: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
}
}
func TestRevokeOtherUsersTokenReturnsNotFound(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "alice's", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Bob attempts to revoke alice's token.
if err := store.Revoke("bob@example.com", tok.ID()); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
// Token must still be valid.
if _, err := store.Validate(plaintext); err != nil {
t.Errorf("token wrongly revoked: %v", err)
}
}
func TestRevokeNonexistentReturnsNotFound(t *testing.T) {
store := newTestStore(t)
if err := store.Revoke("alice@example.com", "deadbeef"); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestRevokeRejectsInvalidIDs(t *testing.T) {
store := newTestStore(t)
for _, id := range []string{"", "xx", "ZZZ", "abc"} {
if err := store.Revoke("alice@example.com", id); !errors.Is(err, ErrNotFound) {
t.Errorf("Revoke(%q) expected ErrNotFound, got %v", id, err)
}
}
}
func TestGenerateRejectsEmptyEmail(t *testing.T) {
store := newTestStore(t)
if _, _, err := store.Generate("", "", time.Time{}); err == nil {
t.Error("expected error for empty email")
}
}
func TestTokenFileMode(t *testing.T) {
store := newTestStore(t)
_, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
info, err := os.Stat(filepath.Join(store.Dir(), tok.Hash))
if err != nil {
t.Fatalf("stat token file: %v", err)
}
if mode := info.Mode().Perm(); mode&0o077 != 0 {
t.Errorf("token file mode %o exposes group/other bits", mode)
}
}
func TestTokenFilenameIsHashNotPlaintext(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Plaintext must not appear as a filename.
entries, err := os.ReadDir(store.Dir())
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), plaintext) {
t.Errorf("plaintext token leaked in filename %q", e.Name())
}
}
// And the hash file must exist.
if _, err := os.Stat(filepath.Join(store.Dir(), tok.Hash)); err != nil {
t.Errorf("hash-named file missing: %v", err)
}
}
func TestExpiredHelper(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
cases := []struct {
expires time.Time
expired bool
}{
{time.Time{}, false}, // zero = no expiry
{now.Add(time.Hour), false}, // future
{now.Add(-time.Hour), true}, // past
{now, true}, // exactly now = expired
}
for _, c := range cases {
tok := Token{Expires: c.expires}
if got := tok.Expired(now); got != c.expired {
t.Errorf("Token{Expires:%v}.Expired(%v) = %v, want %v", c.expires, now, got, c.expired)
}
}
}

View file

@ -27,6 +27,7 @@ type Config struct {
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables 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 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) Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
NoAuth bool // --no-auth / ZDDC_NO_AUTH=1 — skip ACL enforcement entirely. This instance is NOT the security boundary; on master = "open" (anyone reads everything), on a client = "trust upstream's filtering, don't re-evaluate ACLs locally."
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket) 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) 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. OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
@ -86,6 +87,8 @@ func Load(args []string) (Config, error) {
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).") "Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1", 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.") "Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
noAuthFlag := fs.Bool("no-auth", os.Getenv("ZDDC_NO_AUTH") == "1",
"Skip ACL enforcement entirely. On master: anyone reads everything (dev / trusted-LAN / public-read deployments). On client: trust upstream's filtering. Distinct from --insecure (which gates startup-without-.zddc). Default: enforce ACLs.")
opaURLFlag := fs.String("opa-url", getEnv("ZDDC_OPA_URL", "internal"), 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\".") "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", opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
@ -153,6 +156,7 @@ func Load(args []string) (Config, error) {
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag), CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag, AccessLog: *accessLogFlag,
Insecure: *insecureFlag, Insecure: *insecureFlag,
NoAuth: *noAuthFlag,
OPAURL: *opaURLFlag, OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag, OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag, OPACacheTTL: *opaCacheTTLFlag,
@ -274,6 +278,7 @@ func Usage(w io.Writer) {
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = 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-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.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.Bool("no-auth", false, "Skip ACL enforcement entirely. On master: anyone reads everything. On client: trust upstream's filtering. Distinct from --insecure.")
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".") 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.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.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")

View file

@ -2,9 +2,12 @@ package handler
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"strings"
"time" "time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"log/slog" "log/slog"
@ -23,13 +26,46 @@ const EmailKey contextKey = "email"
// "swap internal evaluator for external OPA" plumbing change. // "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider" const DeciderKey contextKey = "policy-decider"
// ACLMiddleware extracts the user email from the configured header and stores // ACLMiddleware extracts the user email and stores it (along with the
// it (along with the policy decider) in the request context. It does NOT // policy decider) in the request context. It does NOT enforce ACL
// enforce ACL itself — each handler performs its own ACL check via // itself — each handler performs its own ACL check via
// policy.AllowFromChain. // policy.AllowFromChain.
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler { //
// Two email sources, in order:
//
// 1. `Authorization: Bearer <token>` — if present, the token is
// validated against the supplied auth.Store. On success, the
// request runs as the token-file's email. On failure (invalid /
// expired / no validator configured), the middleware short-circuits
// with 401 — silently falling back to header-based auth would let
// a misconfigured client masquerade as anonymous.
// 2. Otherwise, the email is read from cfg.EmailHeader, exactly as
// before. This is the upstream-auth-proxy path (oauth2-proxy,
// Caddy auth, etc.) that injects the header on validated requests.
//
// `tokens` may be nil — deployments without the token system simply
// reject any Bearer attempts with 401. This keeps Bearer-vs-no-Bearer
// trust paths decoupled from the operator's choice to issue tokens.
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := r.Header.Get(cfg.EmailHeader) var email string
if bearer := bearerToken(r); bearer != "" {
if tokens == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tok, err := tokens.Validate(bearer)
if err != nil {
if !errors.Is(err, auth.ErrInvalidToken) {
slog.Warn("token validation error", "err", err)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
email = tok.Email
} else {
email = r.Header.Get(cfg.EmailHeader)
}
// DEBUG-level header dump for diagnosing proxy / SSO header // DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with // passthrough. Off by default (LogLevel info); enable with
// ZDDC_LOG_LEVEL=debug. Logs the configured header name, the // ZDDC_LOG_LEVEL=debug. Logs the configured header name, the
@ -50,6 +86,21 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler)
}) })
} }
// bearerToken returns the token value from the Authorization header
// (case-insensitive on the "Bearer" scheme per RFC 6750), or the empty
// string when no Bearer credential is present.
func bearerToken(r *http.Request) string {
v := r.Header.Get("Authorization")
if v == "" {
return ""
}
const prefix = "bearer "
if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(v[len(prefix):])
}
// EmailFromContext extracts the user email from the request context. // EmailFromContext extracts the user email from the request context.
func EmailFromContext(r *http.Request) string { func EmailFromContext(r *http.Request) string {
if v, ok := r.Context().Value(EmailKey).(string); ok { if v, ok := r.Context().Value(EmailKey).(string); ok {

View file

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

View file

@ -0,0 +1,342 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TokensPathPrefix is the URL of the user-facing self-service token
// management page. Browser GET returns an HTML page; the page fetches
// the JSON API at TokensAPIPathPrefix.
const TokensPathPrefix = "/.tokens"
// TokensAPIPathPrefix is the URL prefix for the token JSON API.
//
// GET /.api/tokens → list current user's tokens (metadata only)
// POST /.api/tokens → create a new token; returns plaintext once
// DELETE /.api/tokens/<id> → revoke a token by short ID or full hash
const TokensAPIPathPrefix = "/.api/tokens"
// MaxTokenDescription caps how much free-form text a user can attach
// to a token. Keeps the on-disk YAML small and the HTML rendering
// simple.
const MaxTokenDescription = 200
// MaxTokensPerUser is a soft cap; refusing to create a new token at
// the limit is enough to prevent runaway accumulation. Operators who
// need more can revoke first.
const MaxTokensPerUser = 50
// tokenAPIView is the JSON shape returned to the management UI. Never
// includes the plaintext token (which is only returned on creation).
type tokenAPIView struct {
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// tokenCreateRequest is the body of POST /.api/tokens.
type tokenCreateRequest struct {
Description string `json:"description,omitempty"`
Expires time.Time `json:"expires,omitempty"`
}
// tokenCreateResponse is the body of a successful POST /.api/tokens.
// The plaintext Token is returned exactly once and is never derivable
// from the persisted file again.
type tokenCreateResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// ServeTokensAPI dispatches requests to the JSON token API. Anonymous
// requests are 401; missing token store yields 503 (token issuance is
// disabled when storage isn't reachable).
func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if store == nil {
http.Error(w, "Token store unavailable", http.StatusServiceUnavailable)
return
}
rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix)
switch {
case rest == "" || rest == "/":
switch r.Method {
case http.MethodGet:
handleTokensList(store, email, w)
case http.MethodPost:
handleTokensCreate(store, email, w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(rest, "/"):
id := strings.TrimPrefix(rest, "/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
handleTokensRevoke(store, email, id, w)
default:
w.Header().Set("Allow", "DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
}
func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) {
list, err := store.List(email)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
views := make([]tokenAPIView, 0, len(list))
for _, t := range list {
views = append(views, tokenAPIView{
ID: t.ID(),
Email: t.Email,
Created: t.Created,
Expires: t.Expires,
Description: t.Description,
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(views)
}
func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req tokenCreateRequest
if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
}
desc := strings.TrimSpace(req.Description)
if len(desc) > MaxTokenDescription {
http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest)
return
}
if !req.Expires.IsZero() && req.Expires.Before(time.Now()) {
http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest)
return
}
// Soft cap to prevent runaway accumulation.
existing, err := store.List(email)
if err == nil && len(existing) >= MaxTokensPerUser {
http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict)
return
}
plaintext, tok, err := store.Generate(email, desc, req.Expires)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(tokenCreateResponse{
Token: plaintext,
ID: tok.ID(),
Email: tok.Email,
Created: tok.Created,
Expires: tok.Expires,
Description: tok.Description,
})
}
func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) {
err := store.Revoke(email, id)
if err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
if errors.Is(err, auth.ErrNotFound) {
http.NotFound(w, nil)
return
}
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
}
// ServeTokensPage renders the self-service token management HTML page.
// The page fetches /.api/tokens via JavaScript; the server-side render
// only emits a static skeleton plus the authenticated user's email so
// the page can show "you are signed in as alice@..." without a flash.
func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
return
}
storeAvailable := store != nil
body := renderTokensPage(email, storeAvailable)
_, _ = w.Write([]byte(body))
}
// renderTokensPage builds the HTML for the management page. Kept inline
// (no template files) to match the rest of the handler package; the
// page is small enough that string-concat is readable and there are no
// untrusted-data injection concerns beyond the `email` parameter,
// which is escaped via html.EscapeString.
func renderTokensPage(email string, storeAvailable bool) string {
storeNote := ""
if !storeAvailable {
storeNote = `<p class="warn">Token store is unavailable — the server failed to initialise <code>.zddc.d/tokens/</code>. Operations on this page will return 503.</p>`
}
const css = `
body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
.who { color: #666; margin-bottom: 1.5em; }
fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; }
legend { font-weight: 600; padding: 0 0.4em; }
label { display: block; margin: 0.5em 0; }
label span { display: inline-block; min-width: 9em; }
input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; }
button { padding: 0.4em 0.9em; cursor: pointer; }
.danger { color: #b00; border-color: #b00; }
.warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5em; }
th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; }
.token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; }
.empty { color: #888; font-style: italic; }
`
const js = `
const api = "/.api/tokens";
async function refresh() {
const tbody = document.querySelector("#tokens tbody");
tbody.innerHTML = "";
const r = await fetch(api, { headers: {"Accept": "application/json"} });
if (!r.ok) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">Could not load tokens (' + r.status + ')</td></tr>';
return;
}
const list = await r.json();
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No tokens issued yet.</td></tr>';
return;
}
for (const t of list) {
const tr = document.createElement("tr");
const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)";
const created = new Date(t.created).toLocaleString();
tr.innerHTML = '<td><code>' + escapeHTML(t.id) + '</code></td>'
+ '<td>' + escapeHTML(t.description || "") + '</td>'
+ '<td>' + escapeHTML(created) + '</td>'
+ '<td>' + escapeHTML(expires) + '</td>'
+ '<td><button class="danger" data-id="' + escapeHTML(t.id) + '">Revoke</button></td>';
tbody.appendChild(tr);
}
tbody.querySelectorAll("button.danger").forEach(b => {
b.addEventListener("click", () => revoke(b.dataset.id));
});
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
async function revoke(id) {
if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return;
const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" });
if (!r.ok) {
alert("Revoke failed: " + r.status + " " + r.statusText);
return;
}
refresh();
}
document.getElementById("create").addEventListener("submit", async (ev) => {
ev.preventDefault();
const desc = document.getElementById("desc").value.trim();
const expRaw = document.getElementById("expires").value;
const body = {};
if (desc) body.description = desc;
if (expRaw) body.expires = new Date(expRaw).toISOString();
const r = await fetch(api, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
const out = document.getElementById("created");
if (!r.ok) {
out.innerHTML = '<p class="warn">Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '</p>';
return;
}
const data = await r.json();
out.innerHTML = '<p>New token (copy now it is shown only once):</p><div class="token-secret">' + escapeHTML(data.token) + '</div>';
document.getElementById("desc").value = "";
document.getElementById("expires").value = "";
refresh();
});
refresh();
`
return `<!doctype html>
<html><head><meta charset="utf-8"><title>ZDDC tokens</title>
<style>` + css + `</style>
</head><body>
<h1>API tokens</h1>
<p class="who">Signed in as <strong>` + html.EscapeString(email) + `</strong></p>
` + storeNote + `
<fieldset>
<legend>Create a new token</legend>
<form id="create">
<label><span>Description</span><input type="text" id="desc" maxlength="200" placeholder="e.g. Field laptop"></label>
<label><span>Expires (optional)</span><input type="datetime-local" id="expires"></label>
<button type="submit">Create token</button>
</form>
<div id="created"></div>
</fieldset>
<fieldset>
<legend>Existing tokens</legend>
<table id="tokens">
<thead><tr><th>ID</th><th>Description</th><th>Created</th><th>Expires</th><th></th></tr></thead>
<tbody></tbody>
</table>
</fieldset>
<script>` + js + `</script>
</body></html>`
}

View file

@ -0,0 +1,458 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// newTestTokenStore returns an isolated auth.Store under a TempDir.
func newTestTokenStore(t *testing.T) *auth.Store {
t.Helper()
root := t.TempDir()
store, err := auth.NewStore(root)
if err != nil {
t.Fatalf("auth.NewStore: %v", err)
}
return store
}
// authedReq builds an HTTP request with the authenticated email set in
// the request context (mimicking what ACLMiddleware would do upstream
// of the handler under test).
func authedReq(method, path, email string, body []byte) *http.Request {
var r *http.Request
if body != nil {
r = httptest.NewRequest(method, path, bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, path, nil)
}
if email != "" {
r = r.WithContext(WithEmail(r.Context(), email))
}
return r
}
func TestServeTokensAPI_Anonymous401(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/.api/tokens", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous GET /.api/tokens = %d, want 401", rec.Code)
}
}
func TestServeTokensAPI_NoStore503(t *testing.T) {
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, nil, rec, r)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("nil store = %d, want 503", rec.Code)
}
}
func TestServeTokensAPI_ListEmpty(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusOK {
t.Errorf("GET = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Errorf("Content-Type = %q", got)
}
var list []tokenAPIView
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("unmarshal: %v body=%q", err, rec.Body.String())
}
if len(list) != 0 {
t.Errorf("expected empty list, got %d entries", len(list))
}
}
func TestServeTokensAPI_CreateAndList(t *testing.T) {
store := newTestTokenStore(t)
body := []byte(`{"description":"laptop"}`)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d, want 201; body=%q", rec.Code, rec.Body.String())
}
var resp tokenCreateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal POST resp: %v", err)
}
if resp.Token == "" {
t.Fatal("plaintext token missing from response")
}
if resp.ID == "" {
t.Fatal("ID missing")
}
if resp.Email != "alice@example.com" {
t.Errorf("Email = %q", resp.Email)
}
if resp.Description != "laptop" {
t.Errorf("Description = %q", resp.Description)
}
// The token must validate as a Bearer.
tok, err := store.Validate(resp.Token)
if err != nil {
t.Fatalf("Validate the new plaintext: %v", err)
}
if tok.ID() != resp.ID {
t.Errorf("validated ID %q != response ID %q", tok.ID(), resp.ID)
}
// Subsequent list contains the token (without plaintext).
rec2 := httptest.NewRecorder()
r2 := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec2, r2)
if rec2.Code != http.StatusOK {
t.Fatalf("GET = %d", rec2.Code)
}
var list []tokenAPIView
_ = json.Unmarshal(rec2.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list = %d entries, want 1", len(list))
}
if list[0].ID != resp.ID {
t.Errorf("listed ID mismatch")
}
// Plaintext token MUST NOT appear in any list response.
if strings.Contains(rec2.Body.String(), resp.Token) {
t.Error("plaintext token leaked in list response")
}
}
func TestServeTokensAPI_CreateRejectsPastExpiry(t *testing.T) {
store := newTestTokenStore(t)
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
body := []byte(`{"expires":"` + past + `"}`)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusBadRequest {
t.Errorf("past-expiry create = %d, want 400", rec.Code)
}
}
func TestServeTokensAPI_CreateRejectsLongDescription(t *testing.T) {
store := newTestTokenStore(t)
huge := strings.Repeat("x", MaxTokenDescription+1)
body, _ := json.Marshal(tokenCreateRequest{Description: huge})
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusBadRequest {
t.Errorf("long description = %d, want 400", rec.Code)
}
}
func TestServeTokensAPI_RevokeOwnToken(t *testing.T) {
store := newTestTokenStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed Generate: %v", err)
}
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNoContent {
t.Errorf("DELETE = %d, want 204; body=%q", rec.Code, rec.Body.String())
}
// Token no longer validates.
if _, err := store.Validate(plaintext); err == nil {
t.Error("token still validates after revoke")
}
}
func TestServeTokensAPI_RevokeOtherUsers404(t *testing.T) {
store := newTestTokenStore(t)
_, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "bob@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNotFound {
t.Errorf("DELETE other user's = %d, want 404", rec.Code)
}
}
func TestServeTokensAPI_RevokeMissing404(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/deadbeef", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNotFound {
t.Errorf("DELETE nonexistent = %d, want 404", rec.Code)
}
}
func TestServeTokensAPI_RejectsBadMethods(t *testing.T) {
store := newTestTokenStore(t)
cases := []struct {
path string
method string
}{
{"/.api/tokens", http.MethodPut},
{"/.api/tokens", http.MethodDelete},
{"/.api/tokens/abc12345", http.MethodGet},
{"/.api/tokens/abc12345", http.MethodPost},
}
for _, c := range cases {
rec := httptest.NewRecorder()
r := authedReq(c.method, c.path, "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s = %d, want 405", c.method, c.path, rec.Code)
}
}
}
func TestServeTokensPage_Authenticated(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("GET /.tokens = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Errorf("Content-Type = %q", got)
}
body := rec.Body.String()
if !strings.Contains(body, "alice@example.com") {
t.Error("page does not show authenticated email")
}
if !strings.Contains(body, "/.api/tokens") {
t.Error("page does not reference the API endpoint")
}
}
func TestServeTokensPage_Anonymous401(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/.tokens", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous = %d, want 401", rec.Code)
}
}
func TestServeTokensPage_NoStoreShowsWarning(t *testing.T) {
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, nil, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("authenticated with nil store = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "unavailable") {
t.Error("expected warning about token store unavailability")
}
}
func TestServeTokensPage_RejectsNonGET(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST = %d, want 405", rec.Code)
}
}
func TestACLMiddleware_BearerSetsEmailFromToken(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("bearer-user@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
var seenEmail string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenEmail = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if seenEmail != "bearer-user@example.com" {
t.Errorf("seen email = %q, want bearer-user@example.com", seenEmail)
}
}
func TestACLMiddleware_InvalidBearerRejected(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run on invalid bearer")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
// And a valid header email — Bearer must still take precedence and fail.
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("invalid bearer = %d, want 401", rec.Code)
}
}
func TestACLMiddleware_BearerWithoutStoreRejected(t *testing.T) {
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run when bearer present and store absent")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer something")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("bearer w/ nil store = %d, want 401", rec.Code)
}
}
func TestACLMiddleware_NoBearerFallsBackToHeader(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
var seen string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if seen != "alice@example.com" {
t.Errorf("email = %q, want alice@example.com (from header)", seen)
}
}
func TestACLMiddleware_BearerCaseInsensitive(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
for _, prefix := range []string{"Bearer ", "bearer ", "BEARER ", "BeArEr "} {
var seen string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", prefix+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("prefix %q: status %d", prefix, rec.Code)
}
if seen != "alice@example.com" {
t.Errorf("prefix %q: email %q, want alice@example.com", prefix, seen)
}
}
}
func TestACLMiddleware_ExpiredBearerRejected(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("alice@example.com", "", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run on expired bearer")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expired bearer = %d, want 401", rec.Code)
}
}
// TestEndToEndCreateAuthenticateRevoke walks the full happy path: an
// authenticated user creates a token via the API, uses it as a Bearer
// for a separate request, then revokes it and confirms the next Bearer
// fails.
func TestEndToEndCreateAuthenticateRevoke(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
// 1. Create token via API as alice (authenticated via context).
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", []byte(`{"description":"e2e"}`))
ServeTokensAPI(cfg, store, rec, r)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d", rec.Code)
}
var resp tokenCreateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
// 2. Use the token as a Bearer through ACLMiddleware.
var seen string
bearerHandler := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+resp.Token)
rec2 := httptest.NewRecorder()
bearerHandler.ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK || seen != "alice@example.com" {
t.Fatalf("authenticate: code=%d email=%q", rec2.Code, seen)
}
// 3. Revoke via API as alice.
rec3 := httptest.NewRecorder()
r3 := authedReq(http.MethodDelete, "/.api/tokens/"+resp.ID, "alice@example.com", nil)
ServeTokensAPI(cfg, store, rec3, r3)
if rec3.Code != http.StatusNoContent {
t.Fatalf("revoke: %d", rec3.Code)
}
// 4. Bearer no longer authenticates.
req4 := httptest.NewRequest(http.MethodGet, "/", nil)
req4.Header.Set("Authorization", "Bearer "+resp.Token)
rec4 := httptest.NewRecorder()
bearerHandler.ServeHTTP(rec4, req4)
if rec4.Code != http.StatusUnauthorized {
t.Errorf("post-revoke Bearer = %d, want 401", rec4.Code)
}
}
// Sanity: WithEmail-from-context helper works as expected when used
// directly without the middleware (test seam).
func TestWithEmail(t *testing.T) {
ctx := WithEmail(context.Background(), "carol@example.com")
r := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
if got := EmailFromContext(r); got != "carol@example.com" {
t.Errorf("EmailFromContext = %q", got)
}
}

View file

@ -185,6 +185,20 @@ func New(cfg Config) (Decider, error) {
return &cachingDecider{inner: inner, ttl: ttl}, nil return &cachingDecider{inner: inner, ttl: ttl}, nil
} }
// AllowAllDecider unconditionally permits every request. Used when the
// operator runs zddc-server with --no-auth — that flag declares the
// instance is NOT the ACL boundary (master in a trusted-LAN deployment,
// or client mode where the upstream master enforced ACLs and the local
// instance trusts those filtering decisions). Swap into the decider
// slot at startup; all existing handlers continue to call Allow* and
// see allowed=true.
type AllowAllDecider struct{}
// Allow always returns true with nil error.
func (AllowAllDecider) Allow(_ context.Context, _ AllowInput) (bool, error) {
return true, nil
}
// InternalDecider routes Allow through zddc.AllowedAction with the // InternalDecider routes Allow through zddc.AllowedAction with the
// configured cascade mode and applies the Issued/Received WORM mask // configured cascade mode and applies the Issued/Received WORM mask
// post-decision. No network, no Rego, no new dependencies. // post-decision. No network, no Rego, no new dependencies.