152 lines
7.6 KiB
Go
152 lines
7.6 KiB
Go
package handler
|
||
|
||
// Layer 2 — the SHIPPED DEFAULT POLICY contract.
|
||
//
|
||
// This is the executable truth table for the embedded defaults
|
||
// (internal/zddc/defaults/): role × canonical-path × verb → allow/deny.
|
||
// It pins the document-control access model so a change to the defaults — OR to
|
||
// the engine that resolves them — can't silently alter who-can-do-what. (When
|
||
// the defaults later move into a project-root .zddc.zip of per-depth .zddc
|
||
// files, this test is unchanged: it asserts EFFECTIVE policy, not where the
|
||
// bytes live.)
|
||
//
|
||
// Two layers, deliberately separate:
|
||
// - Layer 1 (engine follows whatever policy says): policy.TestInternalDecider_
|
||
// CascadeScenarios + internal/zddc/{acl,roles,worm}_test.go (synthetic
|
||
// policies) + internal/policy/parity_test.go (InternalDecider ↔ OPA).
|
||
// - Layer 2 (the shipped defaults are correct): THIS file.
|
||
//
|
||
// Decisions go through the same decider the server uses (InternalDecider, which
|
||
// applies the cascade + WORM mask + active-admin bypass), evaluated at the
|
||
// target's logical parent — mirroring authorizeAction. The HTTP plumbing that
|
||
// chooses that path is covered separately by the auth_invariants tests.
|
||
|
||
import (
|
||
"context"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||
)
|
||
|
||
// defaultsMatrixFixture is a minimal operator deployment: it only populates the
|
||
// three standard roles (which the embedded defaults ship empty) plus one admin,
|
||
// and registers party Acme (party_source: ssr gates the peers). Every grant in
|
||
// the matrix below therefore comes from the embedded defaults, not the fixture.
|
||
func defaultsMatrixFixture(t *testing.T) config.Config {
|
||
t.Helper()
|
||
root := t.TempDir()
|
||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||
"admins:\n - admin@x\n"+
|
||
"roles:\n"+
|
||
" document_controller:\n members: [dc@x]\n"+
|
||
" project_team:\n members: [team@x]\n"+
|
||
" observer:\n members: [obs@x]\n")
|
||
mustWriteHelper(t, filepath.Join(root, "Proj/ssr/Acme.yaml"), "kind: SSR\n")
|
||
zddc.InvalidateCache(root)
|
||
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}
|
||
}
|
||
|
||
// canDo reports whether <email> (elevated?) may perform <action> on content in
|
||
// <dir> — the chain is resolved at <dir> (the logical parent of the child being
|
||
// acted on) and routed through the internal decider, exactly as the server's
|
||
// authorizeAction does for a create/write/delete/read.
|
||
func canDo(t *testing.T, cfg config.Config, email string, elevated bool, dir, action string) bool {
|
||
t.Helper()
|
||
p := zddc.Principal{Email: email, Elevated: elevated}
|
||
chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, filepath.FromSlash(dir)))
|
||
if err != nil {
|
||
t.Fatalf("EffectivePolicy(%s): %v", dir, err)
|
||
}
|
||
allowed, _ := policy.AllowActionFromChainP(
|
||
context.Background(), &policy.InternalDecider{}, chain, p, "/"+dir+"/probe", action)
|
||
return allowed
|
||
}
|
||
|
||
func TestDefaultPolicyMatrix(t *testing.T) {
|
||
cfg := defaultsMatrixFixture(t)
|
||
const (
|
||
R = policy.ActionRead
|
||
W = policy.ActionWrite
|
||
C = policy.ActionCreate
|
||
D = policy.ActionDelete
|
||
)
|
||
cases := []struct {
|
||
note string
|
||
who string
|
||
elev bool
|
||
dir string
|
||
action string
|
||
want bool
|
||
}{
|
||
// ── Project root: standard peers only; no create for anyone ──────────
|
||
{"team: read project root", "team@x", false, "Proj", R, true},
|
||
{"observer: read project root", "obs@x", false, "Proj", R, true},
|
||
{"team: NO create at project root", "team@x", false, "Proj", C, false},
|
||
{"DC: NO create at project root", "dc@x", false, "Proj", C, false},
|
||
|
||
// ── working/<party>: DC rwcda, team cr, observer r ───────────────────
|
||
{"DC: create in working", "dc@x", false, "Proj/working/Acme", C, true},
|
||
{"team: create in working", "team@x", false, "Proj/working/Acme", C, true},
|
||
{"team: read working", "team@x", false, "Proj/working/Acme", R, true},
|
||
{"observer: read working", "obs@x", false, "Proj/working/Acme", R, true},
|
||
{"observer: NO create in working", "obs@x", false, "Proj/working/Acme", C, false},
|
||
// nested under working — the path the authorizeAction bug denied
|
||
{"DC: create nested in working", "dc@x", false, "Proj/working/Acme/sub", C, true},
|
||
{"team: create nested in working", "team@x", false, "Proj/working/Acme/sub", C, true},
|
||
|
||
// ── staging / reviewing: team cr ─────────────────────────────────────
|
||
{"team: create in staging", "team@x", false, "Proj/staging/Acme", C, true},
|
||
{"team: create in reviewing", "team@x", false, "Proj/reviewing/Acme", C, true},
|
||
|
||
// ── incoming: DC rwcd, team read-only ────────────────────────────────
|
||
{"DC: create in incoming", "dc@x", false, "Proj/incoming/Acme", C, true},
|
||
{"team: NO create in incoming", "team@x", false, "Proj/incoming/Acme", C, false},
|
||
{"team: read incoming", "team@x", false, "Proj/incoming/Acme", R, true},
|
||
|
||
// ── ssr (party registry): DC rwc, team read-only ─────────────────────
|
||
{"DC: register party (create in ssr)", "dc@x", false, "Proj/ssr", C, true},
|
||
{"team: NO create in ssr", "team@x", false, "Proj/ssr", C, false},
|
||
{"team: read ssr", "team@x", false, "Proj/ssr", R, true},
|
||
|
||
// ── mdl / rsk registers: DC rwcd, team rwc (no delete), observer r ───
|
||
{"DC: create mdl row", "dc@x", false, "Proj/mdl/Acme", C, true},
|
||
{"DC: delete mdl row", "dc@x", false, "Proj/mdl/Acme", D, true},
|
||
{"team: create mdl row", "team@x", false, "Proj/mdl/Acme", C, true},
|
||
{"team: edit mdl row", "team@x", false, "Proj/mdl/Acme", W, true},
|
||
{"team: NO delete mdl row", "team@x", false, "Proj/mdl/Acme", D, false},
|
||
{"observer: NO create mdl row", "obs@x", false, "Proj/mdl/Acme", C, false},
|
||
{"team: create rsk row", "team@x", false, "Proj/rsk/Acme", C, true},
|
||
{"team: edit rsk row", "team@x", false, "Proj/rsk/Acme", W, true},
|
||
{"team: NO delete rsk row", "team@x", false, "Proj/rsk/Acme", D, false},
|
||
|
||
// ── archive WORM: DC create-once, no write/delete; others read ───────
|
||
{"DC: worm-create in received", "dc@x", false, "Proj/archive/Acme/received", C, true},
|
||
{"DC: NO write in WORM received", "dc@x", false, "Proj/archive/Acme/received", W, false},
|
||
{"DC: NO delete in WORM issued", "dc@x", false, "Proj/archive/Acme/issued", D, false},
|
||
{"team: NO create in archive", "team@x", false, "Proj/archive/Acme/issued", C, false},
|
||
{"team: read archive", "team@x", false, "Proj/archive/Acme/issued", R, true},
|
||
|
||
// ── Elevated admin: full bypass (the human escape hatch) ─────────────
|
||
{"elevated admin: bypass WORM write", "admin@x", true, "Proj/archive/Acme/issued", W, true},
|
||
{"elevated admin: create in working", "admin@x", true, "Proj/working/Acme", C, true},
|
||
|
||
// ── Un-elevated admin: NO bypass; not in any role → no grant ─────────
|
||
{"un-elevated admin: NO WORM bypass", "admin@x", false, "Proj/archive/Acme/issued", W, false},
|
||
{"un-elevated admin: NO create in working", "admin@x", false, "Proj/working/Acme", C, false},
|
||
|
||
// ── Anonymous: nothing (a .zddc exists → no public default) ──────────
|
||
{"anon: NO read working", "", false, "Proj/working/Acme", R, false},
|
||
{"anon: NO create working", "", false, "Proj/working/Acme", C, false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
got := canDo(t, cfg, tc.who, tc.elev, tc.dir, tc.action)
|
||
if got != tc.want {
|
||
t.Errorf("%s — canDo(%q, elevated=%v, %s, %q) = %v, want %v",
|
||
tc.note, tc.who, tc.elev, tc.dir, tc.action, got, tc.want)
|
||
}
|
||
}
|
||
}
|