Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.
== Listing protocol ==
GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:
- listing.FileInfo gains an optional `title` field (read from each
directory's own .zddc title:). Generic clients (landing, browse)
read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
virtual:true) when no on-disk file exists at that path and the
caller asked for ?hidden=1. Opens an editable view of the cascade
defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
The bare-root landing serve is Accept-gated: HTML requests get the
landing tool (project picker), JSON requests fall through to
ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
strip trailing slash) — same pattern fetchParties already used at
/<project>/archive/.
== Form editor retirement ==
`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.
- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
browse URL instead of the dead form.
== Admin elevation (Principal model) ==
Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.
- zddc.Principal{Email, Elevated} replaces bare-email arguments on
IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
the elevation gate compiler-enforced at every admin call site —
audit-fragility is gone. The empty-email short-circuit is no longer
load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
are implicitly elevated (CLI clients can't toggle a cookie); browser
sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
bypasses elevation with a synthetic-elevated Principal — different
cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
have admin authority anywhere?") so the header toggle can render
itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
pre-elevation default; tests for the un-elevated gate use the
explicit form).
Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
12 KiB
Go
421 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 allow: [\"*\"]\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 deny: [\"*@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 for "fiefdom"
|
|
// editing is a separate concept covered by IsSubtreeAdmin / CanEditZddc.
|
|
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 allow: [\"*\"]\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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanEditZddc(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
files map[string]string
|
|
dir string
|
|
email string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "root super-admin can edit root .zddc (bootstrap)",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
},
|
|
dir: "",
|
|
email: "root@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "non-admin cannot edit root",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
},
|
|
dir: "",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "no zddc files at all → nobody edits root",
|
|
files: map[string]string{},
|
|
dir: "",
|
|
email: "anyone@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "root super-admin can edit any subtree file",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects/x": "",
|
|
},
|
|
dir: "projects/x",
|
|
email: "root@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subtree admin can edit deeper file (strict ancestor satisfied)",
|
|
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 CANNOT edit their own grant file (no strict ancestor for them)",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "projects",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "subtree admin CANNOT edit root",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
},
|
|
dir: "",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "subtree admin CANNOT edit sibling's grant file",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"foo": "admins:\n - alice@example.com\n",
|
|
"bar": "admins:\n - bob@example.com\n",
|
|
},
|
|
dir: "bar",
|
|
email: "alice@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "two-level delegation — mid-level admin edits leaf below their grant",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
"projects/sub": "admins:\n - bob@example.com\n",
|
|
"projects/sub/x": "",
|
|
},
|
|
dir: "projects/sub/x",
|
|
email: "alice@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "two-level delegation — bob (mid-level admin) cannot edit own grant",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
"projects/sub": "admins:\n - bob@example.com\n",
|
|
},
|
|
dir: "projects/sub",
|
|
email: "bob@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "two-level delegation — bob can still edit deeper",
|
|
files: map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
"projects/sub": "admins:\n - bob@example.com\n",
|
|
"projects/sub/x": "",
|
|
},
|
|
dir: "projects/sub/x",
|
|
email: "bob@example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "mallory in a subdir admins list — original escalation case stays blocked",
|
|
files: map[string]string{
|
|
"": "acl:\n allow: [\"*\"]\n",
|
|
"project": "admins:\n - mallory@example.com\n",
|
|
},
|
|
dir: "project",
|
|
email: "mallory@example.com",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "glob root admin can edit anything",
|
|
files: map[string]string{
|
|
"": "admins:\n - \"*@varasys.io\"\n",
|
|
"projects/x": "",
|
|
},
|
|
dir: "projects/x",
|
|
email: "alice@varasys.io",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "empty email never edits",
|
|
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 := CanEditZddc(root, dir, Principal{Email: tc.email, Elevated: true}); got != tc.want {
|
|
t.Errorf("CanEditZddc(dir=%q, email=%q) = %v, want %v",
|
|
tc.dir, tc.email, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|