diff --git a/AGENTS.md b/AGENTS.md index 57242c9..b7d10fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,4 +356,5 @@ local path that fails loudly and visibly on the developer's terminal. - Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. - ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`) - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". +- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. diff --git a/zddc/README.md b/zddc/README.md index b65c45a..432c820 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -226,6 +226,38 @@ endpoint returns **404** to every caller. Non-admin requests also receive 404 (not 403) so the existence of the admin page is invisible to unauthorized callers. +### Forward-auth target for upstream proxies + +`zddc-server` also exposes `GET /.auth/admin` — a machine-only endpoint that +returns **200** if the caller's resolved email is in the root `.zddc` `admins:` +list, **403** otherwise. No body, no redirect, no UI; it is a pure +authorization decision intended to be polled by an upstream proxy's +forward-auth directive (Caddy `forward_auth`, nginx `auth_request`, Traefik +`ForwardAuth`, etc.). + +The intended use case is gating *adjacent* services on the same pod / host +that don't have their own ACL. Concretely: the dev-shell deployment runs +both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses +`forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach +`/devshell/*` (the IDE) before forwarding. zddc-server's own routes (`/`, +`//`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL +and don't go through this endpoint. + +```caddy +# example: protect /devshell/* with forward_auth on /.auth/admin +handle_path /devshell/* { + forward_auth 127.0.0.1:9090 { + uri /.auth/admin + copy_headers X-Auth-Request-Email + } + reverse_proxy 127.0.0.1:8443 # code-server +} +``` + +The check is cheap (one map lookup against the cached `PolicyChain`); calling +it on every request is fine. Edits to `/srv/.zddc` propagate within the +fsnotify watcher's debounce window (~2 s) — no service restart needed. + ### Caveats - Logs are in-memory and lost on restart. The buffer holds the most recent 500 diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 1ce4805..acb4d46 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -218,6 +218,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // Auth check endpoints — machine-only forward_auth targets used by + // upstream proxies (e.g. the dev-shell pod's Caddy in front of + // code-server) to gate routes on root-admin status. Handled before + // the reserved-prefix guard below so the .auth namespace passes + // through without being 404'd by the dot-prefix rule. + if urlPath == handler.AuthPathPrefix+"/admin" { + handler.ServeAuthAdmin(cfg, w, r) + return + } + // Project list API: GET / with Accept: application/json if urlPath == "/" { accept := r.Header.Get("Accept") diff --git a/zddc/internal/handler/authcheck.go b/zddc/internal/handler/authcheck.go new file mode 100644 index 0000000..3f8863b --- /dev/null +++ b/zddc/internal/handler/authcheck.go @@ -0,0 +1,47 @@ +package handler + +import ( + "net/http" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// AuthPathPrefix is the URL prefix at which machine-only auth-check +// endpoints live. Mirrors ProfilePathPrefix's dot-prefix convention so +// the dispatch's reserved-prefix guard sees it as an internal namespace +// rather than user content. +const AuthPathPrefix = "/.auth" + +// ServeAuthAdmin is a forward_auth target for upstream proxies (e.g. the +// dev-shell pod's Caddy in front of code-server). It returns: +// +// - 200 OK — caller's resolved email is in the root .zddc's +// admins: list, per zddc.IsAdmin. +// - 403 Forbidden — anonymous, or email not in the admins: list, or +// no root .zddc exists. Also covers the case where +// the admins: field is empty/missing. +// +// The endpoint produces no body and does not redirect — it's a pure +// authorization decision intended to be polled by Caddy's forward_auth +// directive (or any equivalent in nginx, Traefik, oauth2-proxy, etc.). +// +// Performance: zddc.IsAdmin is a single map lookup against a cached +// PolicyChain; the .zddc file is parsed once and re-read only when the +// fsnotify watcher fires. Suitable to call on every request without +// noticeable overhead. +// +// Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally +// stricter than the regular acl.allow / acl.deny chain — admin-only +// endpoints (the dev-shell IDE, future maintenance routes) shouldn't +// fall through to subtree-level allowances. For per-route ACL, callers +// continue using the existing handlers (archive, profile, etc.) which +// consult AllowedWithChain. +func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) { + email := EmailFromContext(r) + if email == "" || !zddc.IsAdmin(cfg.Root, email) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/zddc/internal/handler/authcheck_test.go b/zddc/internal/handler/authcheck_test.go new file mode 100644 index 0000000..13f4e05 --- /dev/null +++ b/zddc/internal/handler/authcheck_test.go @@ -0,0 +1,64 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestServeAuthAdmin pins the contract of the forward_auth endpoint +// used by upstream proxies (Caddy in the dev-shell pod) to gate +// admin-only routes: +// +// 200 → caller is in the root .zddc admins: list +// 403 → anonymous, OR not in admins:, OR no admins configured +// +// Reuses the profileTestRoot fixture which materializes a temp .zddc +// with the supplied admins, and the requestWithEmail helper that +// injects the email into request context the same way ACLMiddleware +// would in production. +func TestServeAuthAdmin(t *testing.T) { + cfg, _ := profileTestRoot(t, []string{"alice@example.com"}) + + cases := []struct { + name string + email string + wantStatus int + }{ + {"empty email is denied", "", http.StatusForbidden}, + {"non-admin is denied", "bob@example.com", http.StatusForbidden}, + {"admin is allowed", "alice@example.com", http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", tc.email) + rec := httptest.NewRecorder() + ServeAuthAdmin(cfg, rec, req) + if rec.Code != tc.wantStatus { + t.Errorf("status = %d, want %d (body: %q)", + rec.Code, tc.wantStatus, rec.Body.String()) + } + }) + } +} + +// TestServeAuthAdmin_NoZddcRootDeniesEverything covers the bootstrap- +// state behaviour: when no /srv/.zddc exists, IsAdmin returns false for +// everyone, which means /.auth/admin returns 403 universally. This is +// the desired safe-default before an operator drops a root .zddc onto +// the share. +func TestServeAuthAdmin_NoZddcRootDeniesEverything(t *testing.T) { + // profileTestRoot with nil admins skips writing the file entirely. + cfg, _ := profileTestRoot(t, nil) + + for _, email := range []string{"", "alice@example.com", "anyone@example.com"} { + req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", email) + rec := httptest.NewRecorder() + ServeAuthAdmin(cfg, rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("email %q without .zddc: status %d, want 403", + email, rec.Code) + } + } +}