ZDDC/zddc/internal/handler/auth_invariants_test.go
ZDDC d0d8423ac6 test(handler): un-skip the profile existence-hiding invariant
TestInvariant_ProfileAdminEndpointsHideFromNonAdmins was skipped pending the
ServeProfile dispatcher refactor — which has since landed (ServeProfile in
profilehandler.go is the entry point, with an adminOnly wrapper that denies
with 404). Implement the test against it: non-admin, anonymous, and
un-elevated-admin callers must get 404 (never 403/200) on every admin-gated
sub-resource (/whoami, /config, /logs, /effective-policy, /reindex), so the
namespace can't be enumerated; an elevated admin gets through (/whoami,
/config positive control). Locks in the existence-hiding security property
that was previously unverified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:41:29 -05:00

516 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// auth_invariants_test.go — behavioral lock-in for the admin/elevation/
// WORM invariants. These tests must pass against the CURRENT code before
// the consolidation refactor (single bypass site in InternalDecider) so
// the refactor can be validated against a green baseline.
//
// Each test covers one invariant called out in the security audit. The
// names are deliberately verbose — when one fails, the failure message
// alone tells you which property got broken.
// invariantsFixture sets up a synthetic ZDDC root with:
//
// - admin@example.com — root super-admin
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
// (via per-dir .zddc admins:) — used to test
// subtree scope
// - bob@example.com — document_controller role member (gets WORM cr
// on received/ + issued/ via cascade defaults)
// - eve@example.com — non-admin, project_team only (read-only across
// the project per defaults)
//
// Plus one file each in working/, issued/, received/ so we can exercise
// reads + writes across the cascade.
func invariantsFixture(t *testing.T) (config.Config, string) {
t.Helper()
root := t.TempDir()
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - admin@example.com\n"+
"roles:\n"+
" document_controller:\n members: [bob@example.com]\n"+
" project_team:\n members: [\"*@example.com\"]\n")
for _, d := range []string{
"Project-1/archive/Acme/working/eve@example.com",
"Project-1/archive/Acme/received/Acme-0042",
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
"admins:\n - alice@example.com\n")
// Files to act on.
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/working/eve@example.com/draft.md"),
"# eve's draft\n")
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
"%PDF-A\n")
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"),
"# issued draft\n")
zddc.InvalidateCache(root)
return config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 64 * 1024,
}, root
}
// do executes a request with the given email / elevation flag. URL-encoding
// is computed from the path so spaces and parens (real ZDDC filenames)
// round-trip cleanly.
func doReq(cfg config.Config, method, urlPath, email string, elevated bool, body []byte, op string) *httptest.ResponseRecorder {
u := &url.URL{Path: urlPath}
req := httptest.NewRequest(method, u.RequestURI(), bytes.NewReader(body))
if op != "" {
req.Header.Set(headerOp, op)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
// ── Invariant 1 — Un-elevated admin has no admin authority ────────────────
func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("# mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
// .zddc edits route through the decider as ActionAdmin. The bypass
// for elevated admins fires only when Principal.Elevated is true.
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
// super-admin must return Forbidden.
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
// Positive control: a super-admin who has elevated CAN write any
// .zddc. The decider's IsActiveAdmin short-circuit fires in
// AllowActionFromChainP and the file API write proceeds.
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 2 — Elevated admin can do everything (positive control) ─────
func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("# fix-mis-filed\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated admin write blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 3 — Subtree admin scope ──────────────────────────────────────
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
// alice is subtree admin of Project-1/archive/Acme/working/ — should override eve's
// fenced auto-own and write through.
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
cfg, _ := invariantsFixture(t)
// alice is subtree admin of /Project-1/archive/Acme/working/, NOT of /Project-1/archive/.
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("subtree admin escaped scope: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 4 — .zddc strict-ancestor self-elevation prevention ─────────
// Strict-ancestor was retired — a subtree admin owns their .zddc.
// These tests pin the post-change contract: an elevated admin
// granted in /<dir>/.zddc CAN edit that file (add collaborators,
// adjust ACLs, even — accidentally — remove themselves). Footgun
// is recoverable via super-admin restore.
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working")
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if !zddc.IsAdminForChain(chain, p.Email) {
t.Fatalf("subtree admin lost authority to edit own .zddc — strict-ancestor wasn't supposed to apply")
}
}
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working/eve@example.com")
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if !zddc.IsAdminForChain(chain, p.Email) {
t.Fatalf("subtree admin blocked from editing deeper .zddc")
}
}
// ── Invariant 5 — Empty email never matches ────────────────────────────────
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 6 — WORM cr survives for document_controller (no admin) ─────
func TestInvariant_DocControllerCanCreateInWormZone(t *testing.T) {
cfg, _ := invariantsFixture(t)
// bob is a document_controller (per role membership) but NOT an admin.
// He must be able to CREATE new files in received/<tracking>/ even
// without elevation — the WORM cr grant carries.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_B (RFI) - Followup.pdf"
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-B\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("doc_controller blocked from WORM create: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_DocControllerCannotOverwriteInWormZone(t *testing.T) {
cfg, _ := invariantsFixture(t)
// bob can CREATE in WORM but cannot OVERWRITE — the worm strip
// removes w/d for everyone, even WORM-listed principals.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("doc_controller bypassed WORM overwrite-strip: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 7 — project_team has read but no write ──────────────────────
func TestInvariant_ProjectTeamCanReadCannotWrite(t *testing.T) {
cfg, _ := invariantsFixture(t)
// eve is project_team (r at project level) and the file lives under
// her own working/ home — but she is NOT in any admin list and not
// elevated, so writes must be ACL-gated.
//
// In her own home, eve has auto-own rwcda via the working/<email>/
// auto-own pattern; the cascade gives her create+write there. So
// the right test is a write OUTSIDE her home — into a peer's area
// or into archive.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
rec := doReq(cfg, http.MethodPut, target, "eve@example.com", false, []byte("# eve overwrite\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("project_team escaped WORM strip: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 8 — Forward-auth endpoint requires admin membership ─────────
func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
cfg, _ := invariantsFixture(t)
for _, tc := range []struct {
email string
want int
why string
}{
{"admin@example.com", http.StatusOK, "root admin"},
{"alice@example.com", http.StatusForbidden, "subtree admin only — /.auth/admin gates on ROOT admins:, not subtree"},
{"eve@example.com", http.StatusForbidden, "non-admin"},
{"", http.StatusForbidden, "anonymous"},
} {
req := httptest.NewRequest(http.MethodGet, "/.auth/admin", nil)
ctx := context.WithValue(req.Context(), EmailKey, tc.email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != tc.want {
t.Errorf("/.auth/admin for %q (%s): got %d, want %d",
tc.email, tc.why, rec.Code, tc.want)
}
}
}
// ── Invariant 10 — .zddc write matrix at root / project / subtree ─────────
// TestInvariant_ZddcPutMatrix exercises every (principal × elevation ×
// target) combination for PUT to a .zddc file. The decider's
// IsActiveAdmin short-circuit is the single bypass; this matrix locks
// down that it only fires for an Elevated principal who is named in
// the admins: list of some level on the target's chain.
//
// Targets:
// - /.zddc — root file (root admins: govern)
// - /Project-1/.zddc — project file (no on-disk .zddc;
// write must materialise it; root
// admins still govern via cascade)
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
// this subtree via its own admins:
// list (so alice's write doesn't
// require root-admin authority).
//
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
// the decider, not 404).
func TestInvariant_ZddcPutMatrix(t *testing.T) {
type principal struct {
email string
elevated bool
}
rootAdminElevated := principal{"admin@example.com", true}
rootAdminUnelevated := principal{"admin@example.com", false}
subtreeAdminElevated := principal{"alice@example.com", true}
subtreeAdminUnelevated := principal{"alice@example.com", false}
nonAdmin := principal{"eve@example.com", true}
anon := principal{"", true}
const (
ok = http.StatusOK
den = http.StatusForbidden
)
cases := []struct {
name string
target string
who principal
want int
}{
// Root .zddc
{"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok},
{"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den},
{"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den},
{"non-admin → root .zddc", "/.zddc", nonAdmin, den},
{"anonymous → root .zddc", "/.zddc", anon, den},
// Project .zddc (no on-disk file yet — PUT creates it)
{"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated},
{"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den},
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
// Subtree .zddc (alice administers this subtree)
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok},
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok},
{"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den},
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den},
{"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg, _ := invariantsFixture(t)
body := []byte("title: matrix probe\n")
rec := doReq(cfg, http.MethodPut, tc.target, tc.who.email, tc.who.elevated, body, "")
if tc.want == den {
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
t.Fatalf("want denial, got %d body=%s", rec.Code, dumpBody(rec))
}
} else if rec.Code != tc.want {
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
}
})
}
}
// TestInvariant_ZddcDeleteMatrix mirrors ZddcPutMatrix for DELETE. The
// project-level .zddc target is dropped (no on-disk file → 404 lives
// outside the auth surface). The cases that remain pin: only an
// elevated admin with authority over the .zddc's directory can drop
// the file.
func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
type principal struct {
email string
elevated bool
}
rootAdminElevated := principal{"admin@example.com", true}
rootAdminUnelevated := principal{"admin@example.com", false}
subtreeAdminElevated := principal{"alice@example.com", true}
subtreeAdminUnelevated := principal{"alice@example.com", false}
nonAdmin := principal{"eve@example.com", true}
cases := []struct {
name string
target string
who principal
want int
}{
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent},
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
{"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
{"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg, _ := invariantsFixture(t)
rec := doReq(cfg, http.MethodDelete, tc.target, tc.who.email, tc.who.elevated, nil, "")
if rec.Code != tc.want {
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
}
})
}
}
// ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ──────
// TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the
// elevation gate. For every (admin-flavour × action) tuple, an
// un-elevated admin must behave exactly like a non-admin: they may
// only do what an explicit ACL grant permits. The fixture's admin and
// alice both have NO baseline ACL grant outside their admin scope, so
// every action below MUST 403 — any pass indicates a bypass leak.
func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
cfg, _ := invariantsFixture(t)
type op struct {
method string
path string
body []byte
op string
}
probes := []op{
// .zddc writes (ActionAdmin)
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
{http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""},
{http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""},
// WORM writes (ActionWrite / ActionCreate stripped)
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
{http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""},
// Regular write into someone else's working/ home (no ACL grant)
{http.MethodPut, "/Project-1/archive/Acme/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
}
admins := []struct {
name string
email string
}{
{"root super-admin", "admin@example.com"},
{"subtree admin (alice)", "alice@example.com"},
}
for _, a := range admins {
for _, p := range probes {
t.Run(a.name+" "+p.method+" "+p.path, func(t *testing.T) {
rec := doReq(cfg, p.method, p.path, a.email, false, p.body, p.op)
if rec.Code != http.StatusForbidden {
t.Fatalf("BYPASS LEAK: %s un-elevated reached %s %s with status %d body=%s",
a.email, p.method, p.path, rec.Code, dumpBody(rec))
}
})
}
}
}
// ── Invariant 9 — Profile admin endpoints 404 (not 403) for non-admins ────
func TestInvariant_ProfileAdminEndpointsHideFromNonAdmins(t *testing.T) {
// These checks lock in the existence-hiding property: non-admins must
// see 404, never 403, so they can't probe which admin-only resources
// exist. ServeProfile is the dispatcher (the refactor this test waited
// on); its adminOnly wrapper denies with 404 before the sub-handler
// runs, so a nil ring/index is safe for the non-admin paths.
cfg, _ := invariantsFixture(t)
adminEndpoints := []string{"/whoami", "/config", "/logs", "/effective-policy", "/reindex"}
profileGet := func(sub, email string, elevated bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, ProfilePathPrefix+sub, nil)
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, nil, nil, rec, req)
return rec
}
// Non-admin (eve, project_team only) and anonymous callers must get
// 404 on every admin endpoint — never 403, never 200.
for _, who := range []struct {
email string
elevated bool
label string
}{
{"eve@example.com", false, "non-admin"},
{"", false, "anonymous"},
{"admin@example.com", false, "un-elevated admin"}, // sudo-style: no authority until elevated
} {
for _, sub := range adminEndpoints {
rec := profileGet(sub, who.email, who.elevated)
if rec.Code != http.StatusNotFound {
t.Errorf("%s GET /.profile%s = %d, want 404 (existence-hiding)", who.label, sub, rec.Code)
}
}
}
// Positive control: an elevated root admin must NOT get 404 on the
// gated routes that need no ring/index — proving the 404s above are
// the admin gate, not a missing route. (/whoami and /config don't
// touch the log ring or archive index.)
for _, sub := range []string{"/whoami", "/config"} {
rec := profileGet(sub, "admin@example.com", true)
if rec.Code == http.StatusNotFound {
t.Errorf("elevated admin GET /.profile%s = 404; the gate should admit admins", sub)
}
}
}
// dump prints the rec body when t.Logf would help debugging — used in
// failure messages to avoid silently empty 403 cases.
func dumpBody(rec *httptest.ResponseRecorder) string {
s := rec.Body.String()
return strings.TrimSpace(s)
}