test: lock down elevation gate, .zddc write matrix, audit-log attribution
Four targeted test suites that pin the invariants exercised by the
preceding audit refactor. Closes the coverage gaps identified after the
admin-decider consolidation and the .zddc write-path fix.
internal/policy/principal_test.go (NEW)
TestAllowActionFromChainP_TruthTable — 11 cases × 5 actions = 55
assertions covering every (elevated × admin-at-level × action)
combination. Pins the IsActiveAdmin short-circuit: bypass requires
BOTH (in admins) AND Elevated; elevation alone confers nothing;
empty email never matches.
TestAllowActionFromChainP_AdminScopeDepth — root admin reaches every
path; subtree admin matches in their own subtree; subtree admin
does NOT match in a sibling subtree (the chain doesn't carry
sibling admins lists).
TestAllowActionFromChainP_BypassWinsOverWorm — elevated admin
escape hatch in WORM zones, plus the negative control that an
un-elevated admin does NOT bypass WORM.
internal/handler/auth_invariants_test.go (appended)
TestInvariant_ZddcPutMatrix — 16 sub-cases across (root / project /
subtree .zddc) × (root admin / subtree admin / non-admin /
anonymous) × (elevated / un-elevated). Locks down which principal
can PUT which .zddc.
TestInvariant_ZddcDeleteMatrix — 5 DELETE cases.
TestInvariant_UnelevatedAdminNoSilentBypass — 14 anti-bypass probes:
every (admin-flavour × probe-path) tuple where an un-elevated
admin must 403. Single bypass leak → loud test failure.
cmd/zddc-server/main_test.go (appended)
TestDispatchZddcWriteRouting — full dispatcher path coverage:
GET/HEAD route to ServeZddcFile (YAML or virtual placeholder);
PUT/DELETE route through the .zddc-leaf carve-out into
ServeFileAPI; intermediate .zddc.d/ segments still 404 at the
guard.
internal/handler/middleware_test.go (appended)
TestAccessLog_ChainAdminLevelAttribution — 7 cases pinning the
forensic record: root admin → chain_admin_level=0, subtree admin
in scope → chain_admin_level=N, subtree admin out of scope → -1,
un-elevated admin → -1, non-admin → -1, anonymous → -1.
Cross-checks active_admin == (chain_admin_level >= 0) so a future
refactor can't desync them.
92 new sub-cases total. Coverage delta on the policy package:
76.1% → 87.2%; AllowActionFromChainP 0% → 100%;
activeAdminForRequest 7% → 68%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f196205622
commit
cff840e225
4 changed files with 660 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
|
|
@ -288,6 +289,99 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
|
||||
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
|
||||
// virtual placeholder), and PUT/DELETE/POST falls through past the
|
||||
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
|
||||
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
|
||||
// and the YAML editor's save flow had no live path.
|
||||
func TestDispatchZddcWriteRouting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project-A"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
Root: root,
|
||||
IndexPath: ".archive",
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
MaxWriteBytes: 1 << 20,
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
|
||||
withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
|
||||
ctx := handler.WithEmail(req.Context(), email)
|
||||
ctx = handler.WithElevation(ctx, elevated)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
|
||||
req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
|
||||
t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
|
||||
}
|
||||
|
||||
// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
|
||||
body := []byte("admins:\n - admin@example.com\n - extra@example.com\n")
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Read back via GET to confirm the write landed.
|
||||
req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "extra@example.com") {
|
||||
t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// Project-level .zddc that doesn't exist yet — PUT creates it.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// DELETE removes a .zddc.
|
||||
req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Non-admin elevated still 403 on PUT — the carve-out only opens
|
||||
// the path past the segment guard; the decider gates ActionAdmin.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Intermediate .zddc.d segments stay reserved — only the LEAF .zddc
|
||||
// is carved through. A PUT to /.zddc.d/foo must 404 at the guard.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
|
||||
// to the canonical /<project>/.archive/... so all tracking-number references
|
||||
// converge on a single stable URL per (project, tracking) regardless of the
|
||||
|
|
|
|||
|
|
@ -282,6 +282,178 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 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/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/working/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den},
|
||||
{"anonymous → subtree .zddc", "/Project-1/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/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||
{"non-admin → subtree .zddc", "/Project-1/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/working/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/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/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) {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ package handler
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestAccessLogReadsEmailFromACLContext is a regression test for a bug where
|
||||
|
|
@ -136,3 +140,113 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
|||
t.Errorf("audit log missing status code; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessLog_ChainAdminLevelAttribution pins the audit-log forensic
|
||||
// invariant: every request record carries `chain_admin_level` matching
|
||||
// the .zddc admins: level that conferred admin authority on this
|
||||
// request, or -1 when no admin authority applies. Forensics use this to
|
||||
// distinguish a root-admin write from a subtree-admin write from a
|
||||
// non-admin write — three operationally distinct events that used to
|
||||
// be conflated under a single `is_admin` boolean.
|
||||
//
|
||||
// Truth table the middleware must emit:
|
||||
//
|
||||
// (elevated, in admins at level N) → chain_admin_level=N, active_admin=true
|
||||
// (elevated, in admins at no level) → chain_admin_level=-1, active_admin=false
|
||||
// (not elevated, in admins) → chain_admin_level=-1, active_admin=false
|
||||
// (anonymous, elevation flag ignored) → chain_admin_level=-1, active_admin=false
|
||||
func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
|
||||
// Fixture: root admin at level 0; subtree admin at level 1 (Project-1).
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("write root .zddc: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project-1"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir Project-1: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Project-1", ".zddc"),
|
||||
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("write subtree .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
zddc.InvalidateCache(filepath.Join(root, "Project-1"))
|
||||
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
type record struct {
|
||||
Email string `json:"email"`
|
||||
Elevated bool `json:"elevated"`
|
||||
ActiveAdmin bool `json:"active_admin"`
|
||||
ChainAdminLevel int `json:"chain_admin_level"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
parse := func(t *testing.T, buf *bytes.Buffer) record {
|
||||
t.Helper()
|
||||
var rec record
|
||||
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
|
||||
t.Fatalf("audit log not valid JSON: %v; raw=%s", err, buf.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
elevate bool
|
||||
path string
|
||||
wantLevel int
|
||||
wantActive bool
|
||||
}{
|
||||
{"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true},
|
||||
{"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true},
|
||||
{"subtree admin elevated probing own subtree → level 1", "alice@example.com", true, "/Project-1/", 1, true},
|
||||
{"subtree admin elevated probing root → -1 (out of scope)", "alice@example.com", true, "/", -1, false},
|
||||
{"root admin un-elevated → -1 (no live authority)", "root@example.com", false, "/", -1, false},
|
||||
{"non-admin elevated → -1 (elevation alone confers nothing)", "stranger@example.com", true, "/", -1, false},
|
||||
{"anonymous → -1", "", false, "/", -1, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
if tc.email != "" {
|
||||
req.Header.Set("X-Auth-Request-Email", tc.email)
|
||||
}
|
||||
if tc.elevate {
|
||||
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
|
||||
}
|
||||
chain.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
rec := parse(t, &buf)
|
||||
if rec.ChainAdminLevel != tc.wantLevel {
|
||||
t.Errorf("chain_admin_level = %d, want %d", rec.ChainAdminLevel, tc.wantLevel)
|
||||
}
|
||||
if rec.ActiveAdmin != tc.wantActive {
|
||||
t.Errorf("active_admin = %v, want %v", rec.ActiveAdmin, tc.wantActive)
|
||||
}
|
||||
// active_admin is the projection of chain_admin_level — these
|
||||
// two fields must agree on every record. Asserted explicitly
|
||||
// so a future refactor that drops the chain_admin_level field
|
||||
// (or recomputes active_admin from a different source) trips
|
||||
// this test before the forensic invariant rots.
|
||||
if rec.ActiveAdmin != (rec.ChainAdminLevel >= 0) {
|
||||
t.Errorf("active_admin must equal (chain_admin_level >= 0); got active=%v level=%d",
|
||||
rec.ActiveAdmin, rec.ChainAdminLevel)
|
||||
}
|
||||
// Elevation flag must round-trip independently — distinguishes
|
||||
// "tried to elevate, no authority" (elevated=true, active=false)
|
||||
// from "didn't elevate" (elevated=false, active=false).
|
||||
if rec.Elevated != tc.elevate {
|
||||
t.Errorf("elevated = %v, want %v", rec.Elevated, tc.elevate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
280
zddc/internal/policy/principal_test.go
Normal file
280
zddc/internal/policy/principal_test.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestAllowActionFromChainP_TruthTable pins the principal-aware decider
|
||||
// across the full {elevated × admin-at-level-N × action} cross-product.
|
||||
// This is the single bypass site that consolidates every former
|
||||
// scattered IsAdmin/IsSubtreeAdmin/CanEditZddc check in handler code,
|
||||
// so its semantics must be locked in by an exhaustive table.
|
||||
//
|
||||
// Invariants pinned:
|
||||
//
|
||||
// 1. Admin bypass requires BOTH (Email in admins:) AND Elevated.
|
||||
// - In admins + elevated → bypass (any action returns true)
|
||||
// - In admins + un-elevated → no bypass (falls through to ACL)
|
||||
// - Not in admins + elevated → no bypass
|
||||
// - Empty email + elevated → no bypass (gate() rejects empty)
|
||||
//
|
||||
// 2. Bypass is action-agnostic: ActionRead, ActionWrite, ActionCreate,
|
||||
// ActionDelete, ActionAdmin all behave the same way under bypass.
|
||||
//
|
||||
// 3. Admin authority at ANY level on the chain confers bypass
|
||||
// (root admin gets bypass even on deep paths; subtree admin
|
||||
// declared at level N gets bypass for level ≥ N).
|
||||
//
|
||||
// 4. With no bypass, the cascade ACL governs:
|
||||
// - rwcd grant → ActionRead/Write/Create/Delete succeed, ActionAdmin denied
|
||||
// - no grant + has_any_file → all actions denied
|
||||
// - empty chain → all actions allowed (public default)
|
||||
func TestAllowActionFromChainP_TruthTable(t *testing.T) {
|
||||
// Chain shape used throughout: root admins:[root@example.com] +
|
||||
// level 1 admins:[sub@example.com] + level 1 ACL allowing
|
||||
// staff@example.com rwcd.
|
||||
chain := zddc.PolicyChain{
|
||||
HasAnyFile: true,
|
||||
Levels: []zddc.ZddcFile{
|
||||
{Admins: []string{"root@example.com"}},
|
||||
{
|
||||
Admins: []string{"sub@example.com"},
|
||||
ACL: zddc.ACLRules{Permissions: map[string]string{
|
||||
"staff@example.com": "rwcd",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type want struct {
|
||||
read, write, create, deleteV, adminV bool
|
||||
}
|
||||
allActions := want{true, true, true, true, true}
|
||||
noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
elevated bool
|
||||
want want
|
||||
}{
|
||||
// ─── BYPASS PATH ────────────────────────────────────────────
|
||||
{
|
||||
name: "root admin elevated → bypass on every action",
|
||||
email: "root@example.com",
|
||||
elevated: true,
|
||||
want: allActions,
|
||||
},
|
||||
{
|
||||
name: "subtree admin elevated → bypass on every action",
|
||||
email: "sub@example.com",
|
||||
elevated: true,
|
||||
want: allActions,
|
||||
},
|
||||
|
||||
// ─── ELEVATION GATE ─────────────────────────────────────────
|
||||
// An admin who hasn't elevated MUST be treated as a normal
|
||||
// user. They don't carry any baseline ACL grant in this
|
||||
// fixture, so every action is denied.
|
||||
{
|
||||
name: "root admin NOT elevated → no bypass, no ACL grant → all denied",
|
||||
email: "root@example.com",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
},
|
||||
{
|
||||
name: "subtree admin NOT elevated → no bypass, no ACL grant → all denied",
|
||||
email: "sub@example.com",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
},
|
||||
|
||||
// ─── NON-ADMIN PATHS ────────────────────────────────────────
|
||||
{
|
||||
name: "non-admin with rwcd grant → ACL governs, admin denied",
|
||||
email: "staff@example.com",
|
||||
elevated: false,
|
||||
want: noAdmin,
|
||||
},
|
||||
{
|
||||
name: "non-admin elevated → elevation alone confers nothing",
|
||||
email: "staff@example.com",
|
||||
elevated: true,
|
||||
want: noAdmin,
|
||||
},
|
||||
{
|
||||
name: "stranger denied across the board",
|
||||
email: "rando@example.com",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
},
|
||||
{
|
||||
name: "stranger elevated still denied",
|
||||
email: "rando@example.com",
|
||||
elevated: true,
|
||||
want: want{},
|
||||
},
|
||||
|
||||
// ─── ANONYMOUS / DEGENERATE ─────────────────────────────────
|
||||
{
|
||||
name: "empty email + elevated → gate rejects, no bypass",
|
||||
email: "",
|
||||
elevated: true,
|
||||
want: want{},
|
||||
},
|
||||
{
|
||||
name: "empty email + not elevated → denied",
|
||||
email: "",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
},
|
||||
}
|
||||
|
||||
d := &InternalDecider{}
|
||||
ctx := context.Background()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := zddc.Principal{Email: tc.email, Elevated: tc.elevated}
|
||||
check := func(action string, want bool) {
|
||||
t.Helper()
|
||||
got, err := AllowActionFromChainP(ctx, d, chain, p, "/sub/file", action)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: unexpected error: %v", action, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: got %v, want %v", action, got, want)
|
||||
}
|
||||
}
|
||||
check(ActionRead, tc.want.read)
|
||||
check(ActionWrite, tc.want.write)
|
||||
check(ActionCreate, tc.want.create)
|
||||
check(ActionDelete, tc.want.deleteV)
|
||||
check(ActionAdmin, tc.want.adminV)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowActionFromChainP_AdminScopeDepth: admin authority at the
|
||||
// root level cascades to every depth; subtree admin authority declared
|
||||
// at level N applies only when level N is on the queried chain. The
|
||||
// decider doesn't synthesise admin authority — it derives it from
|
||||
// IsAdminForChain, which walks the chain it was given.
|
||||
func TestAllowActionFromChainP_AdminScopeDepth(t *testing.T) {
|
||||
rootOnly := zddc.PolicyChain{
|
||||
HasAnyFile: true,
|
||||
Levels: []zddc.ZddcFile{
|
||||
{Admins: []string{"root@example.com"}},
|
||||
},
|
||||
}
|
||||
rootPlusProject := zddc.PolicyChain{
|
||||
HasAnyFile: true,
|
||||
Levels: []zddc.ZddcFile{
|
||||
{Admins: []string{"root@example.com"}},
|
||||
{Admins: []string{"alice@example.com"}},
|
||||
},
|
||||
}
|
||||
siblingChain := zddc.PolicyChain{
|
||||
HasAnyFile: true,
|
||||
Levels: []zddc.ZddcFile{
|
||||
{Admins: []string{"root@example.com"}},
|
||||
// Sibling project — alice is NOT in this chain's admins.
|
||||
{Admins: []string{"bob@example.com"}},
|
||||
},
|
||||
}
|
||||
|
||||
d := &InternalDecider{}
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain zddc.PolicyChain
|
||||
email string
|
||||
path string
|
||||
wantPut bool
|
||||
}{
|
||||
{
|
||||
name: "root admin reaches a root-only path",
|
||||
chain: rootOnly,
|
||||
email: "root@example.com",
|
||||
path: "/file",
|
||||
wantPut: true,
|
||||
},
|
||||
{
|
||||
name: "root admin reaches a deep path",
|
||||
chain: rootPlusProject,
|
||||
email: "root@example.com",
|
||||
path: "/Project-A/file",
|
||||
wantPut: true,
|
||||
},
|
||||
{
|
||||
name: "subtree admin reaches their own subtree",
|
||||
chain: rootPlusProject,
|
||||
email: "alice@example.com",
|
||||
path: "/Project-A/file",
|
||||
wantPut: true,
|
||||
},
|
||||
{
|
||||
name: "subtree admin does NOT reach a sibling subtree",
|
||||
chain: siblingChain,
|
||||
email: "alice@example.com",
|
||||
path: "/Project-B/file",
|
||||
wantPut: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := zddc.Principal{Email: tc.email, Elevated: true}
|
||||
got, _ := AllowActionFromChainP(ctx, d, tc.chain, p, tc.path, ActionWrite)
|
||||
if got != tc.wantPut {
|
||||
t.Errorf("AllowActionFromChainP write: got %v, want %v", got, tc.wantPut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowActionFromChainP_BypassWinsOverWorm: an elevated admin's
|
||||
// bypass fires before WORM evaluation, so a mis-filed document under
|
||||
// received/ or issued/ can still be corrected. This is the explicit
|
||||
// human escape hatch documented in the policy package comment.
|
||||
func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) {
|
||||
trueP := true
|
||||
chain := zddc.PolicyChain{
|
||||
HasAnyFile: true,
|
||||
Levels: []zddc.ZddcFile{
|
||||
{Admins: []string{"root@example.com"}},
|
||||
{
|
||||
// WORM zone (received/issued style). Without admin bypass,
|
||||
// every write would be stripped.
|
||||
Worm: []string{"_doc_controller"},
|
||||
ACL: zddc.ACLRules{Inherit: &trueP},
|
||||
},
|
||||
},
|
||||
}
|
||||
d := &InternalDecider{}
|
||||
ctx := context.Background()
|
||||
|
||||
p := zddc.Principal{Email: "root@example.com", Elevated: true}
|
||||
for _, action := range []string{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionAdmin} {
|
||||
t.Run("elevated admin in WORM zone — "+action, func(t *testing.T) {
|
||||
got, _ := AllowActionFromChainP(ctx, d, chain, p, "/received/x", action)
|
||||
if !got {
|
||||
t.Errorf("elevated admin %s denied inside WORM zone", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Negative control: same principal un-elevated must NOT bypass WORM.
|
||||
pUn := zddc.Principal{Email: "root@example.com", Elevated: false}
|
||||
for _, action := range []string{ActionWrite, ActionDelete, ActionAdmin} {
|
||||
t.Run("un-elevated admin in WORM zone — "+action, func(t *testing.T) {
|
||||
got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/x", action)
|
||||
if got {
|
||||
t.Errorf("un-elevated admin %s allowed inside WORM zone (bypass leaked)", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue