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>
138 lines
5.3 KiB
Go
138 lines
5.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
// TestAccessLogReadsEmailFromACLContext is a regression test for a bug where
|
|
// the access-log middleware logged email=anonymous on every request because
|
|
// it sat OUTSIDE the ACL middleware in the chain — Go's context propagates
|
|
// down via r.WithContext, not back up through the call chain, so an outer
|
|
// middleware can't read a context value set by an inner one after
|
|
// next.ServeHTTP returns. Fix: ACLMiddleware must wrap AccessLogMiddleware
|
|
// (ACL outer), not the other way around.
|
|
func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
|
|
// Capture slog output so we can assert on what AccessLogMiddleware logged.
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
defer slog.SetDefault(prev)
|
|
|
|
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
|
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
|
// email from the context ACL populated.
|
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
|
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
|
chain.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
got := buf.String()
|
|
if !strings.Contains(got, `email=alice@example.com`) {
|
|
t.Errorf("expected access log to contain email=alice@example.com, got: %s", got)
|
|
}
|
|
if strings.Contains(got, `email=anonymous`) {
|
|
t.Errorf("access log fell back to email=anonymous despite header being set; ACL/AccessLog order may have regressed: %s", got)
|
|
}
|
|
}
|
|
|
|
// TestAccessLogAnonymousWhenNoEmail confirms that when the configured email
|
|
// header is absent, the access log still records email=anonymous as expected.
|
|
func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
defer slog.SetDefault(prev)
|
|
|
|
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
|
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
|
// Note: no X-Auth-Request-Email header set.
|
|
chain.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
got := buf.String()
|
|
if !strings.Contains(got, `email=anonymous`) {
|
|
t.Errorf("expected access log to contain email=anonymous when header absent, got: %s", got)
|
|
}
|
|
}
|
|
|
|
// TestAccessLogOuterDoesNotSeeInnerContext is a guard test that locks down
|
|
// the underlying Go behavior: putting AccessLog OUTER and ACL INNER produces
|
|
// the original bug (email=anonymous despite the header being set). If this
|
|
// test ever fails, Go's context propagation has changed in a way that lets
|
|
// inner-middleware context values flow back up — which would mean the
|
|
// reordering fix can be reverted.
|
|
func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
defer slog.SetDefault(prev)
|
|
|
|
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
|
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Inverted order — the ORIGINAL buggy chain.
|
|
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
|
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
|
chain.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
got := buf.String()
|
|
if strings.Contains(got, `email=alice@example.com`) {
|
|
t.Fatalf("Go's context propagation behavior changed — AccessLog (outer) somehow saw the email ACL (inner) set. The middleware reordering in main.go is no longer required and could be reverted. Log: %s", got)
|
|
}
|
|
if !strings.Contains(got, `email=anonymous`) {
|
|
t.Errorf("expected the inverted (buggy) chain to fall back to email=anonymous, got: %s", got)
|
|
}
|
|
}
|
|
|
|
// TestAccessLogMiddleware_AuditLoggerReceivesSameFields verifies the
|
|
// optional audit-logger argument: when non-nil, it gets a parallel copy
|
|
// of every access record. Used by main.go to tee access logs to a
|
|
// rotating file in addition to stderr.
|
|
func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
|
var auditBuf bytes.Buffer
|
|
auditLogger := slog.New(slog.NewJSONHandler(&auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusTeapot)
|
|
_, _ = w.Write([]byte("hi"))
|
|
})
|
|
|
|
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
|
|
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
|
|
chain.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
out := auditBuf.String()
|
|
if !strings.Contains(out, `"email":"bob@example.com"`) {
|
|
t.Errorf("audit log missing email field; got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"path":"/some/path"`) {
|
|
t.Errorf("audit log missing path; got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"status":418`) {
|
|
t.Errorf("audit log missing status code; got: %s", out)
|
|
}
|
|
}
|