feat(zddc-server): /.auth/admin forward_auth endpoint
A machine-only HTTP endpoint that returns 200 if the request's X-Auth-Request-Email is in the root .zddc admins: list, 403 otherwise. No body, no redirect — pure authorization decision intended to be polled by an upstream proxy's forward_auth directive. The motivating use case is gating /devshell/* (code-server) in the dev-shell pod on root-admin status before the request ever reaches code-server, which has no built-in ACL of its own. zddc-server's own routes keep the existing .zddc cascade ACL and don't go through this endpoint. Reuses zddc.IsAdmin (one cached map lookup) so the check is cheap enough to call on every request. Edits to /srv/.zddc propagate via the existing fsnotify watcher's policy-cache invalidation. Tests cover empty email, non-admin, admin, and the bootstrap state where no root .zddc exists (deny everyone — the safe default). Docs: zddc/README.md "Forward-auth target for upstream proxies" section + AGENTS.md notes bullet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
17b0a4dff9
commit
6cc0d2ae27
5 changed files with 154 additions and 0 deletions
|
|
@ -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: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
- ACL is enforced via cascading `.zddc` YAML files; 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".
|
- `.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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
(not 403) so the existence of the admin page is invisible to unauthorized
|
||||||
callers.
|
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 (`/`,
|
||||||
|
`/<project>/`, `/.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
|
### Caveats
|
||||||
|
|
||||||
- Logs are in-memory and lost on restart. The buffer holds the most recent 500
|
- Logs are in-memory and lost on restart. The buffer holds the most recent 500
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
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
|
// Project list API: GET / with Accept: application/json
|
||||||
if urlPath == "/" {
|
if urlPath == "/" {
|
||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get("Accept")
|
||||||
|
|
|
||||||
47
zddc/internal/handler/authcheck.go
Normal file
47
zddc/internal/handler/authcheck.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
64
zddc/internal/handler/authcheck_test.go
Normal file
64
zddc/internal/handler/authcheck_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue