Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
419 lines
12 KiB
Go
419 lines
12 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsAdmin(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
zddcBody string // contents of <root>/.zddc; empty string means no file
|
|
email string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no zddc file → not admin",
|
|
zddcBody: "",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "zddc file with no admins key → not admin",
|
|
zddcBody: "acl:\n permissions:\n \"*\": rwcd\n",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "zddc file with empty admins list → not admin",
|
|
zddcBody: "admins: []\n",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "exact-match admin → admin",
|
|
zddcBody: "admins:\n - alice@example.com\n",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "domain glob admin → admin",
|
|
zddcBody: "admins:\n - \"*@example.com\"\n",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "domain glob admin, wrong domain → not admin",
|
|
zddcBody: "admins:\n - \"*@example.com\"\n",
|
|
email: "alice@other.org",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "non-matching listed admin → not admin",
|
|
zddcBody: "admins:\n - bob@example.com\n",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty email never matches even if pattern is *",
|
|
zddcBody: "admins:\n - \"*\"\n",
|
|
email: "",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "acl deny does not affect admins",
|
|
zddcBody: "acl:\n permissions:\n \"*@example.com\": \"\"\nadmins:\n - alice@example.com\n",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
if tc.zddcBody != "" {
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(tc.zddcBody), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
}
|
|
if got := IsAdmin(root, Principal{Email: tc.email, Elevated: true}); got != tc.want {
|
|
t.Errorf("IsAdmin(%q, %q) = %v, want %v", root, tc.email, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsAdminSubdirIgnored documents that admins entries in subdirectory
|
|
// .zddc files are NOT honored by IsAdmin — only the root .zddc grants the
|
|
// server-wide super-admin role. Subtree admin authority is a separate
|
|
// concept covered by IsSubtreeAdmin.
|
|
func TestIsAdminSubdirIgnored(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "project")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Root has no admins; subdir tries to grant admin.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
|
|
t.Fatalf("write root .zddc: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sub, ".zddc"), []byte("admins:\n - mallory@example.com\n"), 0o644); err != nil {
|
|
t.Fatalf("write subdir .zddc: %v", err)
|
|
}
|
|
|
|
if IsAdmin(root, Principal{Email: "mallory@example.com", Elevated: true}) {
|
|
t.Error("subdir .zddc admins entry was honored — that is a privilege-escalation hole")
|
|
}
|
|
}
|
|
|
|
// TestIsAdminUnelevated asserts the elevation gate: an admin email
|
|
// who hasn't opted into their powers (Elevated:false) is treated as a
|
|
// non-admin, even when the root .zddc grants them.
|
|
func TestIsAdminUnelevated(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
if IsAdmin(root, Principal{Email: "alice@example.com", Elevated: false}) {
|
|
t.Error("un-elevated admin reported as IsAdmin — elevation gate broken")
|
|
}
|
|
if !IsAdmin(root, Principal{Email: "alice@example.com", Elevated: true}) {
|
|
t.Error("elevated admin not recognised — gate is too strict")
|
|
}
|
|
}
|
|
|
|
// fixture writes a tree of .zddc files. Keys are paths relative to root;
|
|
// the empty string means root itself ("<root>/.zddc"). Values are file
|
|
// contents. Intermediate directories are created as needed. Each path is
|
|
// joined with ".zddc".
|
|
func writeZddcTree(t *testing.T, root string, files map[string]string) {
|
|
t.Helper()
|
|
for rel, body := range files {
|
|
dir := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
// Always invalidate cache before/after writes inside a test so
|
|
// subsequent calls re-read disk. Tests run with t.TempDir() so
|
|
// there's no cross-test contamination, but the in-process cache
|
|
// is global and may carry stale entries between subtests if a
|
|
// prior subtest read the same path.
|
|
InvalidateCache(dir)
|
|
if body == "" {
|
|
continue
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s/.zddc: %v", rel, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsSubtreeAdmin(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
files map[string]string
|
|
dir string // relative to root
|
|
email string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no zddc anywhere → not admin",
|
|
files: map[string]string{},
|
|
dir: "",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "root admin → admin of any subtree",
|
|
files: map[string]string{
|
|
"": "admins:\n - alice@example.com\n",
|
|
"projects/x": "",
|
|
},
|
|
dir: "projects/x",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin granted at intermediate level",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
"projects/x": "",
|
|
},
|
|
dir: "projects/x",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin granted at the leaf level itself",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "projects",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "non-admin in same subtree → not admin",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "projects",
|
|
email: "bob@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "admin granted in sibling subtree does not leak",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"foo": "admins:\n - alice@example.com\n",
|
|
"bar": "",
|
|
},
|
|
dir: "bar",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "glob admin",
|
|
files: map[string]string{
|
|
"": "admins:\n - \"*@varasys.io\"\n",
|
|
"projects": "",
|
|
},
|
|
dir: "projects",
|
|
email: "alice@varasys.io",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "empty email never admin",
|
|
files: map[string]string{"": "admins:\n - \"*\"\n"},
|
|
dir: "",
|
|
email: "",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeZddcTree(t, root, tc.files)
|
|
dir := filepath.Join(root, tc.dir)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir target: %v", err)
|
|
}
|
|
InvalidateCache(dir)
|
|
if got := IsSubtreeAdmin(root, dir, Principal{Email: tc.email, Elevated: true}); got != tc.want {
|
|
t.Errorf("IsSubtreeAdmin(%q, %q) = %v, want %v",
|
|
tc.dir, tc.email, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsAdminForChain pins the unified helper that backs IsAdmin and
|
|
// IsSubtreeAdmin. Each table entry covers one property: cascade walk,
|
|
// role resolution scope, the bootstrap case for the root file, empty-
|
|
// email refusal.
|
|
func TestIsAdminForChain(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
files map[string]string
|
|
dir string
|
|
email string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "root super-admin matches at any depth",
|
|
files: map[string]string{"": "admins:\n - alice@example.com\n"},
|
|
dir: "a/b/c",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin matches inside their subtree",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"project": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "project/sub",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin matches at their grant level (no strict-ancestor)",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"project": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "project",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin does NOT match outside their subtree",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"project1": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "project2",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty email never matches",
|
|
files: map[string]string{"": "admins:\n - \"*\"\n"},
|
|
dir: "",
|
|
email: "",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "role membership in admins entry resolves through cascade",
|
|
files: map[string]string{
|
|
"": "roles:\n controllers:\n members: [bob@example.com]\n" +
|
|
"admins:\n - controllers\n",
|
|
},
|
|
dir: "",
|
|
email: "bob@example.com",
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeZddcTree(t, root, tc.files)
|
|
dir := filepath.Join(root, tc.dir)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
InvalidateCache(dir)
|
|
chain, err := EffectivePolicy(root, dir)
|
|
if err != nil {
|
|
t.Fatalf("EffectivePolicy: %v", err)
|
|
}
|
|
if got := IsAdminForChain(chain, tc.email); got != tc.want {
|
|
t.Errorf("IsAdminForChain(dir=%q, email=%q) = %v, want %v",
|
|
tc.dir, tc.email, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAdminLevelInChain pins the level-index contract used by audit
|
|
// logging: root match returns 0, subtree match returns the deeper
|
|
// index, no match returns -1.
|
|
func TestAdminLevelInChain(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
files map[string]string
|
|
dir string
|
|
email string
|
|
want int
|
|
}{
|
|
{
|
|
name: "root super-admin matches at level 0",
|
|
files: map[string]string{"": "admins:\n - alice@example.com\n"},
|
|
dir: "project",
|
|
email: "alice@example.com",
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "subtree admin matches at level 1",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"project": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "project",
|
|
email: "alice@example.com",
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "deeper subtree admin matches at level 2",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"project": "admins:\n - alice@example.com\n",
|
|
"project/sub": "admins:\n - bob@example.com\n",
|
|
},
|
|
dir: "project/sub",
|
|
email: "bob@example.com",
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "no match returns -1",
|
|
files: map[string]string{"": "admins:\n - root@example.com\n"},
|
|
dir: "project",
|
|
email: "stranger@other.org",
|
|
want: -1,
|
|
},
|
|
{
|
|
name: "empty email returns -1",
|
|
files: map[string]string{"": "admins:\n - \"*\"\n"},
|
|
dir: "",
|
|
email: "",
|
|
want: -1,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeZddcTree(t, root, tc.files)
|
|
dir := filepath.Join(root, tc.dir)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
InvalidateCache(dir)
|
|
chain, err := EffectivePolicy(root, dir)
|
|
if err != nil {
|
|
t.Fatalf("EffectivePolicy: %v", err)
|
|
}
|
|
if got := AdminLevelInChain(chain, tc.email); got != tc.want {
|
|
t.Errorf("AdminLevelInChain(dir=%q, email=%q) = %d, want %d",
|
|
tc.dir, tc.email, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|