feat(zddc-server): delegated subtree admins + built-in .zddc editor

Generalize the admin model from "single root super-admin" to a
delegated chain: a `<dir>/.zddc/admins` list grants admin authority
for that subtree, with a strict-ancestor rule preventing
self-elevation (you cannot edit the .zddc that grants your own
authority — only files strictly below it).

Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir>
so subtree admins can manage their fiefdoms without filesystem
access. JSON API at /.admin/zddc covers GET (file + effective chain
+ can_edit), POST (atomic write + cache invalidation), DELETE,
plus a /tree endpoint listing every .zddc visible to the caller.
Optional theming via <root>/.admin.css.

Validation: glob syntax check, root-self-demotion rejection,
reserved-prefix path guard, YAML round-trip sanity. Writes are
atomic (temp file + fsync + rename) and invalidate the policy
cache.

Also includes the prior in-flight `Title` field on ProjectInfo
so per-project .zddc titles surface on the landing-page picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-04-29 12:52:06 -05:00
parent cf4101b9e4
commit e44ccc3500
17 changed files with 2030 additions and 29 deletions

View file

@ -18,25 +18,34 @@ import (
// resolution. If a real conflict ever shows up, make this a config value. // resolution. If a real conflict ever shows up, make this a config value.
const AdminPathPrefix = "/.admin" const AdminPathPrefix = "/.admin"
// ServeAdmin is the entry point for /.admin/* routes. It enforces the // ServeAdmin is the entry point for /.admin/* routes. The /whoami,
// admins-allowlist gate (returns 404 on non-admin so the existence of the // /config, /logs, and dashboard sub-routes are super-admin-only (gated
// admin page is not leaked) and dispatches to a sub-handler. // by zddc.IsAdmin against the root .zddc); 404 leaks no information
// about admin endpoint existence.
// //
// Auth model: a request is admin if EmailFromContext(r) matches an entry in // /.admin/zddc/* — the .zddc editor — is reachable to ANY subtree-admin
// the Admins list of <cfg.Root>/.zddc. See zddc.IsAdmin. // (not just root), so it is dispatched out to ServeZddc before the
// super-admin gate; ServeZddc applies its own broader hasAnyAdminScope
// check internally.
func ServeAdmin(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) { func ServeAdmin(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
// Trim the prefix, keep a leading "/" for sub-route matching. // Trim the prefix, keep a leading "/" for sub-route matching.
sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix) sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix)
if sub == "" { if sub == "" {
sub = "/" sub = "/"
} }
// /.admin/zddc/* — subtree admins reach this; ServeZddc gates itself.
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
ServeZddc(cfg, w, r)
return
}
email := EmailFromContext(r)
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
switch sub { switch sub {
case "/", "": case "/", "":
serveAdminDashboard(w, r) serveAdminDashboard(w, r)

View file

@ -13,9 +13,14 @@ import (
) )
// ProjectInfo is a single entry in the project list response. // ProjectInfo is a single entry in the project list response.
//
// Title is read from the project's own .zddc file (its `title:` field) when
// present; absent or empty means the landing page shows just the directory
// name. omitempty keeps the JSON small for projects without titles.
type ProjectInfo struct { type ProjectInfo struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
Title string `json:"title,omitempty"`
} }
// ServeProjectList handles GET / with Accept: application/json. // ServeProjectList handles GET / with Accept: application/json.
@ -53,9 +58,15 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request)
if !zddc.AllowedWithChain(chain, email) { if !zddc.AllowedWithChain(chain, email) {
continue continue
} }
// Title comes from <project>/.zddc — optional, ignored on parse error.
var title string
if zf, err := zddc.ParseFile(filepath.Join(absPath, ".zddc")); err == nil {
title = zf.Title
}
projects = append(projects, ProjectInfo{ projects = append(projects, ProjectInfo{
Name: name, Name: name,
URL: "/" + name + "/", URL: "/" + name + "/",
Title: title,
}) })
} }

View file

@ -66,3 +66,52 @@ func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
} }
} }
} }
// TestServeProjectListIncludesTitleFromPerProjectZddc verifies a project's own
// .zddc `title:` field surfaces in the JSON response; projects without it (or
// without any .zddc) come back with an empty/absent title.
func TestServeProjectListIncludesTitleFromPerProjectZddc(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"176109", "197072"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
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(root, "176109", ".zddc"),
[]byte("title: \"Greenfield Substation\"\n"), 0o644); err != nil {
t.Fatalf("write project .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProjectList(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var got []ProjectInfo
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
titles := map[string]string{}
for _, p := range got {
titles[p.Name] = p.Title
}
if titles["176109"] != "Greenfield Substation" {
t.Errorf("176109 title = %q, want %q", titles["176109"], "Greenfield Substation")
}
if titles["197072"] != "" {
t.Errorf("197072 title = %q, want empty", titles["197072"])
}
}

View file

@ -0,0 +1,58 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// adminCustomCSSName is the on-disk filename a server operator places at
// the root to theme the admin pages. It deliberately uses the .admin.css
// suffix (not just custom.css) so it pattern-matches the .zddc / .admin
// reserved-prefix family, and so anyone scanning the root tree sees it
// is admin-related.
const adminCustomCSSName = ".admin.css"
// hasCustomAdminCSS reports whether <fsRoot>/.admin.css exists. The
// editor template uses this to conditionally inject the <link> tag.
func hasCustomAdminCSS(fsRoot string) bool {
_, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName))
return err == nil
}
// zddcAssetsPathPrefix is the URL prefix for admin-only static assets.
// They sit under /.admin/zddc/assets/ rather than /.admin/assets/ so
// they share the editor's broader auth gate (subtree-or-super-admin)
// instead of /.admin/'s super-admin-only gate — otherwise a subtree
// admin would 404 on the custom CSS link emitted by the editor page.
const zddcAssetsPathPrefix = ZddcAdminPathPrefix + "/assets"
// serveZddcAssets handles /.admin/zddc/assets/<file>. V1 only ships
// `custom.css` (passthrough of <root>/.admin.css when present); other
// paths return 404 so we don't accidentally expose arbitrary files.
// hasAnyAdminScope has already gated the request via ServeZddc.
func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
rest := strings.TrimPrefix(r.URL.Path, zddcAssetsPathPrefix+"/")
switch rest {
case "custom.css":
path := filepath.Join(cfg.Root, adminCustomCSSName)
fi, err := os.Stat(path)
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, path)
default:
http.NotFound(w, r)
}
}

View file

@ -0,0 +1,345 @@
package handler
import (
"html/template"
"net/http"
"os"
"path/filepath"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// editorView is the data passed to the editor template. Field naming is
// kept short for template ergonomics.
type editorView struct {
Path string
IsRoot bool
CanEdit bool
Exists bool
Email string
HasCustomCSS bool
File zddc.ZddcFile
EffectiveChain []chainEntry
AdminPathPrefix string // /.admin
AssetsPathPrefix string // /.admin/zddc/assets
}
// serveZddcEditor renders the form-based .zddc editor at
// GET /.admin/zddc/edit?path=<dir>. The form posts JSON back to
// /.admin/zddc?path=<dir>; the inline JS shim handles dynamic-row
// add/remove and surfaces field errors from the JSON response.
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, err := os.Stat(filepath.Join(abs, ".zddc")); err == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
view := editorView{
Path: urlPathOf(cfg.Root, abs),
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
Exists: exists,
Email: email,
HasCustomCSS: hasCustomAdminCSS(cfg.Root),
File: zf,
EffectiveChain: entries,
AdminPathPrefix: AdminPathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := editorTemplate.Execute(w, view); err != nil {
// Headers may already be flushed; best effort.
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// editorTemplate is the html/template body for the editor page.
//
// Style choices:
// - inline CSS uses the same custom-property naming as shared/base.css
// so a future server-side merge with shared/base.css remains trivial.
// - inline JS is one IIFE, ~80 lines, handling: add/remove row,
// collect-into-JSON-on-submit, render server-side field errors.
// - the form falls back to a plain HTTP POST (urlencoded) without JS;
// a tiny same-handler endpoint accepts urlencoded too. (V1: JS only;
// no-JS fallback is documented as a TODO in the file header.)
var editorTemplate = template.Must(template.New("editor").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>.zddc editor {{ .Path }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
}
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
.breadcrumb a { color: var(--primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.ro-banner { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .9rem; margin-bottom: 1rem; }
.ro-banner.read-only { border-color: var(--warn); }
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
.card h2 { margin-top: 0; }
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
label { display: block; margin-bottom: .5rem; }
input[type="text"] { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
input[type="text"]:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
.row input[type="text"] { flex: 1; max-width: none; }
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); }
button:hover { background: var(--primary-bg); }
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
.actions { display: flex; gap: .5rem; align-items: center; margin-top: 1.2rem; }
.actions .spacer { flex: 1; }
.chain { font-size: .9em; }
.chain details { margin-bottom: .25rem; }
.chain summary { cursor: pointer; }
.chain pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0 .5rem; overflow-x: auto; }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
fieldset[disabled] input, fieldset[disabled] button { opacity: .55; cursor: not-allowed; }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb">
<a href="{{ .AdminPathPrefix }}/">&larr; admin</a> &nbsp;/&nbsp;
<a href="{{ .AdminPathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} &nbsp;/&nbsp; <span>{{ .Path }}</span>{{ end }}
</div>
<h1>.zddc editor</h1>
<p class="muted">{{ if .IsRoot }}Editing the root <code>/.zddc</code>.{{ else }}Editing <code>{{ .Path }}/.zddc</code>.{{ end }} You are signed in as <code>{{ .Email }}</code>.</p>
{{ if not .CanEdit }}
<div class="ro-banner read-only"><strong>Read-only.</strong> You can view this file's contents and the inherited rules below, but you do not have permission to edit it. Subtree admins cannot edit the .zddc file that grants their own authority only an admin from a higher level can.</div>
{{ end }}
<div id="ok-banner" class="ok-banner" hidden>Saved.</div>
<form id="editor" autocomplete="off">
<fieldset {{ if not .CanEdit }}disabled{{ end }} style="border:0; padding:0; margin:0;">
<section class="card">
<h2>Title</h2>
<p class="help">Surfaced on the project picker for this folder. Optional projects without a title show their directory name.</p>
<input type="text" name="title" id="f-title" maxlength="200" value="{{ .File.Title }}">
</section>
<section class="card">
<h2>ACL Allow</h2>
<p class="help">Email-glob patterns for users granted access here. Examples: <code>*@example.com</code>, <code>alice@*</code>, <code>alice@example.com</code>. <code>*</code> matches any non-empty email but does not cross the <code>@</code> boundary.</p>
<div class="list" data-field="acl.allow">
{{ range $i, $v := .File.ACL.Allow }}<div class="row"><input type="text" data-field="acl.allow[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
</section>
<section class="card">
<h2>ACL Deny</h2>
<p class="help">Deny is checked first; a parent allow cannot override a deeper deny. Same glob syntax as Allow.</p>
<div class="list" data-field="acl.deny">
{{ range $i, $v := .File.ACL.Deny }}<div class="row"><input type="text" data-field="acl.deny[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
</section>
<section class="card">
<h2>{{ if .IsRoot }}Super-admins (bootstrap){{ else }}Subtree admins of {{ .Path }}{{ end }}</h2>
<p class="help">
{{ if .IsRoot }}Anyone here is an unrestricted admin of the entire server. They can edit any <code>.zddc</code> file, including this one. The very first super-admin is created by hand-editing this file at server install time. <strong>You cannot remove yourself</strong> from this list.
{{ else }}Anyone here can edit <code>.zddc</code> files anywhere <em>below</em> this directory. They <strong>cannot</strong> edit this file (where their authority comes from), so they cannot remove their delegator or add peers at their own level.
{{ end }}
</p>
<div class="list" data-field="admins">
{{ range $i, $v := .File.Admins }}<div class="row"><input type="text" data-field="admins[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
</section>
<section class="card chain">
<details {{ if not .Exists }}open{{ end }}>
<summary>Effective chain (inherited rules)</summary>
{{ range .EffectiveChain }}<details><summary><code>{{ .Dir }}/.zddc</code> {{ if not .Exists }}<span class="muted">(no file at this level)</span>{{ end }}</summary><pre>title: {{ .Title }}
allow: {{ range .ACL.Allow }}{{ . }} {{ end }}
deny: {{ range .ACL.Deny }}{{ . }} {{ end }}
admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
</details>
</section>
<div class="actions">
<button type="submit" class="primary" id="save">Save</button>
<button type="button" id="del" class="danger" {{ if .IsRoot }}disabled title="Cannot delete root .zddc"{{ end }}>Delete file</button>
<span class="spacer"></span>
<a href="?path={{ .Path }}">Cancel / refresh</a>
</div>
</fieldset>
</form>
<script>
(function() {
var path = {{ .Path }};
var canEdit = {{ .CanEdit }};
var isRoot = {{ .IsRoot }};
var apiURL = "{{ .AdminPathPrefix }}/zddc?path=" + encodeURIComponent(path);
function rowFor(field, value) {
var div = document.createElement("div");
div.className = "row";
var input = document.createElement("input");
input.type = "text";
input.dataset.field = field;
input.value = value || "";
var del = document.createElement("button");
del.type = "button"; del.className = "del"; del.textContent = "";
var err = document.createElement("span");
err.className = "err";
div.appendChild(input); div.appendChild(del); div.appendChild(err);
return div;
}
document.querySelectorAll("button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
var list = document.querySelector('.list[data-field="' + field + '"]');
var n = list.querySelectorAll(".row").length;
list.appendChild(rowFor(field + "[" + n + "]"));
});
});
document.addEventListener("click", function(e) {
if (e.target && e.target.matches(".del")) {
e.target.closest(".row").remove();
}
});
function collect() {
var out = { title: "", acl: { allow: [], deny: [] }, admins: [] };
out.title = document.getElementById("f-title").value;
document.querySelectorAll('.list[data-field="acl.allow"] input').forEach(function(i) { if (i.value.trim()) out.acl.allow.push(i.value.trim()); });
document.querySelectorAll('.list[data-field="acl.deny"] input').forEach(function(i) { if (i.value.trim()) out.acl.deny.push(i.value.trim()); });
document.querySelectorAll('.list[data-field="admins"] input').forEach(function(i) { if (i.value.trim()) out.admins.push(i.value.trim()); });
return out;
}
function clearErrors() {
document.querySelectorAll(".row .err").forEach(function(e) { e.textContent = ""; });
document.getElementById("ok-banner").hidden = true;
}
function showErrors(errs) {
errs.forEach(function(e) {
var sel = '[data-field="' + CSS.escape(e.field) + '"]';
var input = document.querySelector(sel);
if (input) {
var span = input.parentElement.querySelector(".err");
if (span) span.textContent = e.message;
} else {
// Top-level field error (e.g. "admins" without index, or "title").
var card = document.querySelector('h2 + .help, [name="' + e.field + '"]');
alert(e.field + ": " + e.message);
}
});
}
if (canEdit) {
document.getElementById("editor").addEventListener("submit", function(ev) {
ev.preventDefault();
clearErrors();
var body = JSON.stringify(collect());
fetch(apiURL, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: body
}).then(function(r) {
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function(res) {
if (res.ok) {
document.getElementById("ok-banner").hidden = false;
window.scrollTo(0, 0);
return;
}
try {
var parsed = JSON.parse(res.text);
if (parsed.errors) { showErrors(parsed.errors); return; }
} catch (e) {}
alert("HTTP " + res.status + ": " + res.text);
}).catch(function(err) {
alert(String(err));
});
});
if (!isRoot) {
document.getElementById("del").addEventListener("click", function() {
if (!confirm("Delete " + path + "/.zddc?\n\nInherited rules from parent .zddc files will still apply.")) return;
fetch(apiURL, { method: "DELETE", credentials: "same-origin" }).then(function(r) {
if (r.ok) {
window.location.href = "?path=" + encodeURIComponent(path);
} else {
r.text().then(function(t) { alert("HTTP " + r.status + ": " + t); });
}
});
});
}
}
})();
</script>
</body>
</html>
`))

View file

@ -0,0 +1,349 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ZddcAdminPathPrefix is the URL prefix for the .zddc editor (both API and
// HTML page). All routes under this prefix require either super-admin
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
// receive 404 to keep editor existence hidden, matching the /.admin gate.
const ZddcAdminPathPrefix = AdminPathPrefix + "/zddc"
// ServeZddc dispatches all /.admin/zddc/* routes. ServeAdmin already
// trimmed the /.admin prefix and confirmed at least the super-admin gate,
// but this handler is also reachable for subtree-only admins, so it
// re-checks authorization itself and bypasses the super-admin requirement
// imposed at the top of ServeAdmin.
//
// Sub-routes:
// GET /.admin/zddc?path=<dir> → JSON: parsed file + chain
// POST /.admin/zddc?path=<dir> → write (JSON body)
// DELETE /.admin/zddc?path=<dir> → remove file
// GET /.admin/zddc/tree → JSON: list of editable dirs
// GET /.admin/zddc/edit?path=<dir> → server-rendered editor page
func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
// Hide existence from anyone who has no admin authority anywhere.
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
// r.URL.Path is the full URL path; sub-route is everything after
// /.admin/zddc.
sub := strings.TrimPrefix(r.URL.Path, ZddcAdminPathPrefix)
switch {
case sub == "" || sub == "/":
serveZddcAPI(cfg, w, r)
case sub == "/tree":
serveZddcTree(cfg, w, r)
case sub == "/edit":
serveZddcEditor(cfg, w, r)
case strings.HasPrefix(sub, "/assets/"):
serveZddcAssets(cfg, w, r)
default:
http.NotFound(w, r)
}
}
// hasAnyAdminScope reports whether email has admin authority anywhere in
// the tree (root super-admin OR a subtree-admin grant on any .zddc).
// This is the gate for surfacing the editor at all.
func hasAnyAdminScope(fsRoot, email string) bool {
if email == "" {
return false
}
if zddc.IsAdmin(fsRoot, email) {
return true
}
dirs, _ := zddc.ScanZddcFiles(fsRoot)
for _, d := range dirs {
if zddc.IsSubtreeAdmin(fsRoot, d, email) {
return true
}
}
return false
}
// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// rejects path traversal and any segment beginning with '.' or '_' so
// reserved namespaces (e.g. .devshell) are not editable through this
// API. Returns the cleaned absolute path or an error suitable for a 404.
func resolvePath(fsRoot, urlPath string) (string, error) {
urlPath = strings.TrimSpace(urlPath)
if urlPath == "" {
urlPath = "/"
}
if !strings.HasPrefix(urlPath, "/") {
return "", errors.New("path must be absolute (start with /)")
}
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
// Reject reserved-prefix segments so the editor cannot create
// .foo/.zddc or _bar/.zddc through the API.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", errors.New("reserved-prefix path segment")
}
}
rel := strings.TrimPrefix(cleanURL, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", errors.New("path escapes root")
}
return abs, nil
}
// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. The result is "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
if abs == fsRoot {
return "/"
}
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return "/"
}
return "/" + filepath.ToSlash(rel)
}
// chainEntry is one level of the effective-chain in API responses.
type chainEntry struct {
Dir string `json:"dir"`
Exists bool `json:"exists"`
Title string `json:"title,omitempty"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins,omitempty"`
}
type zddcGetResponse struct {
Path string `json:"path"`
Exists bool `json:"exists"`
IsRoot bool `json:"is_root"`
CanEdit bool `json:"can_edit"`
File zddc.ZddcFile `json:"file"`
EffectiveChain []chainEntry `json:"effective_chain"`
}
type zddcWriteRequest struct {
Title string `json:"title"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins"`
}
type writeError struct {
Errors []zddc.FieldError `json:"errors"`
}
// serveZddcAPI handles /.admin/zddc?path=<dir> for GET, POST, DELETE.
func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
serveZddcGet(cfg, abs, email, w, r)
case http.MethodPost, http.MethodPut:
serveZddcWrite(cfg, abs, email, w, r)
case http.MethodDelete:
serveZddcDelete(cfg, abs, email, w, r)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func serveZddcGet(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Bad Request: cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, statErr := os.Stat(filepath.Join(abs, ".zddc")); statErr == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
resp := zddcGetResponse{
Path: urlPathOf(cfg.Root, abs),
Exists: exists,
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
File: zf,
EffectiveChain: entries,
}
writeJSON(w, resp)
}
// chainDirs reproduces EffectivePolicy's directory walk so the chainEntry
// list can carry the directory of each level. Kept local to this file to
// avoid widening the zddc package's API.
func chainDirs(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
dirs := []string{fsRoot}
if dirPath == fsRoot {
return dirs
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || rel == "." {
return dirs
}
current := fsRoot
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := os.MkdirAll(abs, 0o755); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req zddcWriteRequest
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
zf := zddc.ZddcFile{
Title: req.Title,
ACL: req.ACL,
Admins: req.Admins,
}
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: errs})
return
}
// Root-only invariant: writer must remain in the post-write Admins
// list. Recovery requires filesystem access we don't have.
if abs == cfg.Root {
stillAdmin := false
for _, p := range zf.Admins {
if zddc.MatchesPattern(p, email) {
stillAdmin = true
break
}
}
if !stillAdmin {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{
Field: "admins",
Message: "you cannot remove yourself from the root admins list",
}}})
return
}
}
if err := zddc.WriteFile(abs, zf); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true, "path": urlPathOf(cfg.Root, abs)})
}
func serveZddcDelete(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Root-only invariant: cannot delete root .zddc, that would leave no
// way to administer the server (and CanEditZddc(root) would return
// false on any subsequent request).
if abs == cfg.Root {
http.Error(w, "Cannot delete root .zddc — edit instead", http.StatusBadRequest)
return
}
if err := zddc.DeleteFile(abs); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type treeEntry struct {
Path string `json:"path"`
CanEdit bool `json:"can_edit"`
Title string `json:"title,omitempty"`
}
// serveZddcTree returns the list of every directory containing a .zddc
// file, with a per-entry can_edit flag. The list is filtered to entries
// the caller has at least admin visibility for (read or edit).
func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
continue
}
var title string
if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
title = zf.Title
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
Title: title,
})
}
writeJSON(w, out)
}

View file

@ -0,0 +1,362 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// zddcTestSetup writes a tree of .zddc files and returns the root and a
// helper that builds requests with an injected user email. files keys
// are paths relative to root; the empty string is the root itself. Each
// path is created as a directory; if the value is non-empty it is
// written as that directory's .zddc.
func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
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)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do = func(method, target, email, body string) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
}
var req *http.Request
if rdr != nil {
req = httptest.NewRequest(method, target, rdr)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
return rec
}
return cfg, do
}
func TestServeZddcAuthGate(t *testing.T) {
// root admin = root@example.com; subtree admin alice@example.com on /projects.
cfg, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
})
cases := []struct {
name string
method string
target string
email string
wantStatus int
}{
{"anon GET root", http.MethodGet, "/.admin/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.admin/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.admin/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.admin/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.admin/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.admin/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.admin/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.admin/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.admin/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
}
_ = cfg
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.method == http.MethodPost {
if tc.target == "/.admin/zddc?path=/" {
// Root POST: writer must remain in admins list.
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
} else {
body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}`
}
}
rec := do(tc.method, tc.target, tc.email, body)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeZddcGetReturnsChain(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n",
"projects": "title: All Projects\n",
"projects/sub": "title: Substation\n",
})
rec := do(http.MethodGet, "/.admin/zddc?path=/projects/sub", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Path != "/projects/sub" {
t.Errorf("path = %q, want /projects/sub", resp.Path)
}
if !resp.CanEdit {
t.Errorf("CanEdit = false; root admin should edit anywhere")
}
if !resp.Exists {
t.Errorf("Exists = false but file was written")
}
if len(resp.EffectiveChain) != 3 {
t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain))
}
if resp.EffectiveChain[0].Dir != "/" {
t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir)
}
if resp.EffectiveChain[1].Dir != "/projects" {
t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir)
}
if resp.EffectiveChain[2].Title != "Substation" {
t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title)
}
}
func TestServeZddcPostValidatesGlob(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var we writeError
if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil {
t.Fatalf("decode err body: %v", err)
}
if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" {
t.Errorf("expected acl.allow[0] error, got %+v", we.Errors)
}
}
func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n - bob@example.com\n",
})
// root tries to remove themselves, leaving only bob.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
// root adds bob alongside themselves — fine.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcWriteRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.admin/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.File.Title != "Engineering" {
t.Errorf("title round-trip = %q, want Engineering", resp.File.Title)
}
if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" {
t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins)
}
}
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
})
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
rec := do(http.MethodGet, "/.admin/zddc/tree", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var entries []treeEntry
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("decode: %v", err)
}
seen := map[string]bool{}
for _, e := range entries {
seen[e.Path] = true
}
if !seen["/alpha"] || !seen["/alpha/x"] {
t.Errorf("alice should see /alpha and /alpha/x; got %v", seen)
}
if seen["/beta"] {
t.Errorf("alice should NOT see /beta; got %v", seen)
}
}
func TestServeZddcEditorRenders(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "title: Engineering\n",
})
rec := do(http.MethodGet, "/.admin/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Engineering") {
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
}
if !strings.Contains(body, "/.admin/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.admin/zddc?path=")
}
if !strings.Contains(body, "Subtree admins of /projects") {
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
}
}
func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
})
// alice viewing her own grant file: read-only.
rec := do(http.MethodGet, "/.admin/zddc/edit?path=/projects", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Read-only") {
t.Errorf("editor should show Read-only banner for non-editor; body lacks it")
}
}
func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
rec := do(http.MethodGet, "/.admin/zddc?path="+p, "root@example.com", "")
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
}
}
}
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
// Confirm that putting /.admin/zddc/* under the broader gate did not
// regress the super-admin gate on /.admin/whoami etc.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeAdmin(cfg, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.admin/whoami got %d, want 404", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/.admin/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeAdmin(cfg, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.admin/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcAssetsCustomCSS(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil {
t.Fatalf("write .admin.css: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("Content-Type = %q, want text/css...", ct)
}
if !strings.Contains(rec.Body.String(), "color: red") {
t.Errorf("body does not contain custom CSS")
}
}
func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
}

View file

@ -10,12 +10,12 @@ import "strings"
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) { func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
// deny checked first // deny checked first
for _, pattern := range level.ACL.Deny { for _, pattern := range level.ACL.Deny {
if matchesPattern(pattern, email) { if MatchesPattern(pattern, email) {
return false, true return false, true
} }
} }
for _, pattern := range level.ACL.Allow { for _, pattern := range level.ACL.Allow {
if matchesPattern(pattern, email) { if MatchesPattern(pattern, email) {
return true, true return true, true
} }
} }
@ -36,7 +36,7 @@ func AllowedWithChain(chain PolicyChain, email string) bool {
return !chain.HasAnyFile return !chain.HasAnyFile
} }
// matchesPattern checks if email matches a glob pattern. // MatchesPattern checks if email matches a glob pattern.
// //
// The pattern may use * as a wildcard within the local part or domain part, // The pattern may use * as a wildcard within the local part or domain part,
// but * does not cross the @ boundary. Examples: // but * does not cross the @ boundary. Examples:
@ -44,7 +44,12 @@ func AllowedWithChain(chain PolicyChain, email string) bool {
// - "alice@*" matches alice at any domain // - "alice@*" matches alice at any domain
// - "alice@example.com" matches exactly // - "alice@example.com" matches exactly
// - "*" matches any non-empty email (the @ boundary rule means * must stay in one segment) // - "*" matches any non-empty email (the @ boundary rule means * must stay in one segment)
func matchesPattern(pattern, email string) bool { //
// Exported so handlers can reuse it — for example, to verify that the
// writer of a root .zddc remains in the Admins list after the edit, the
// editor's POST handler calls MatchesPattern directly rather than going
// through AllowedAtLevel/IsAdmin/etc.
func MatchesPattern(pattern, email string) bool {
// Exact match (fast path) // Exact match (fast path)
if pattern == email { if pattern == email {
return true return true

View file

@ -81,15 +81,15 @@ func TestMatchesPattern(t *testing.T) {
{"alice@example.com", "alice", false}, {"alice@example.com", "alice", false},
// Empty email: lone "*" should still match per docstring? Actually globMatch("*", "") = true // Empty email: lone "*" should still match per docstring? Actually globMatch("*", "") = true
// But matchesPattern("*", "") splits "*" on @ → ["*"]. Then globMatch("*", "") = true. // But MatchesPattern("*", "") splits "*" on @ → ["*"]. Then globMatch("*", "") = true.
// The docstring says "matches any non-empty email" but the implementation matches empty too. // The docstring says "matches any non-empty email" but the implementation matches empty too.
// Document the actual behavior in the test. // Document the actual behavior in the test.
{"*", "", true}, {"*", "", true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.pattern+"|"+tc.email, func(t *testing.T) { t.Run(tc.pattern+"|"+tc.email, func(t *testing.T) {
if got := matchesPattern(tc.pattern, tc.email); got != tc.want { if got := MatchesPattern(tc.pattern, tc.email); got != tc.want {
t.Errorf("matchesPattern(%q, %q) = %v, want %v", tc.pattern, tc.email, got, tc.want) t.Errorf("MatchesPattern(%q, %q) = %v, want %v", tc.pattern, tc.email, got, tc.want)
} }
}) })
} }

View file

@ -4,12 +4,15 @@ import "path/filepath"
// IsAdmin reports whether email is listed in the admins entry of the ROOT // IsAdmin reports whether email is listed in the admins entry of the ROOT
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are // .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
// deliberately ignored — admin grants are a server-wide role, and honoring // deliberately ignored by this function — it gates the server-wide debug
// them in subtrees would let anyone with subtree write access elevate // admin role (/.admin/{whoami,config,logs}) which only the bootstrap
// themselves. // super-admin should reach.
//
// Subtree-scoped admin authority (the "fiefdom" model) is checked via
// IsSubtreeAdmin / CanEditZddc instead.
// //
// Patterns use the same glob syntax as acl.allow / acl.deny (see // Patterns use the same glob syntax as acl.allow / acl.deny (see
// matchesPattern). Returns false if the root file does not exist, has an // MatchesPattern). Returns false if the root file does not exist, has an
// empty Admins list, or no entry matches. An empty email never matches. // empty Admins list, or no entry matches. An empty email never matches.
func IsAdmin(fsRoot, email string) bool { func IsAdmin(fsRoot, email string) bool {
if email == "" { if email == "" {
@ -20,9 +23,82 @@ func IsAdmin(fsRoot, email string) bool {
return false return false
} }
for _, pattern := range zf.Admins { for _, pattern := range zf.Admins {
if matchesPattern(pattern, email) { if MatchesPattern(pattern, email) {
return true return true
} }
} }
return false return false
} }
// IsSubtreeAdmin reports whether email administers the subtree rooted at
// dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath.
//
// This is the read-side check — "can email *see* admin tools for this
// subtree?". For write authority over a specific .zddc file, use
// CanEditZddc, which adds the strict-ancestor rule that prevents
// self-elevation.
func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
if email == "" {
return false
}
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
for _, level := range chain.Levels {
for _, pattern := range level.Admins {
if MatchesPattern(pattern, email) {
return true
}
}
}
return false
}
// CanEditZddc reports whether email may write or delete <dirPath>/.zddc.
//
// The strict-ancestor rule: authority must come from a .zddc file STRICTLY
// ABOVE dirPath. An admin granted in <dirPath>/.zddc cannot edit that same
// file (which is what grants their own authority) — they can only edit
// .zddc files in deeper subtrees. This prevents a subtree admin from
// adding peers at their own level, removing their delegator, or otherwise
// elevating themselves.
//
// The root file <fsRoot>/.zddc is the bootstrap exception: it has no
// strict ancestor, so it is governed by its own Admins list (the same
// allowlist IsAdmin checks). The very first super-admin is created by
// hand-editing this file at server install time.
func CanEditZddc(fsRoot, dirPath, email string) bool {
if email == "" {
return false
}
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil || len(chain.Levels) == 0 {
return false
}
// Bootstrap: the root file is governed by its own Admins.
if dirPath == fsRoot {
for _, pattern := range chain.Levels[0].Admins {
if MatchesPattern(pattern, email) {
return true
}
}
return false
}
// Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath.
// EffectivePolicy returns levels ordered root (index 0) → leaf (last).
for i := 0; i < len(chain.Levels)-1; i++ {
for _, pattern := range chain.Levels[i].Admins {
if MatchesPattern(pattern, email) {
return true
}
}
}
return false
}

View file

@ -85,8 +85,9 @@ func TestIsAdmin(t *testing.T) {
} }
// TestIsAdminSubdirIgnored documents that admins entries in subdirectory // TestIsAdminSubdirIgnored documents that admins entries in subdirectory
// .zddc files are NOT honored — only the root .zddc grants admin. Otherwise // .zddc files are NOT honored by IsAdmin — only the root .zddc grants the
// anyone with subtree write access could elevate themselves. // server-wide super-admin role. Subtree admin authority for "fiefdom"
// editing is a separate concept covered by IsSubtreeAdmin / CanEditZddc.
func TestIsAdminSubdirIgnored(t *testing.T) { func TestIsAdminSubdirIgnored(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sub := filepath.Join(root, "project") sub := filepath.Join(root, "project")
@ -106,3 +107,298 @@ func TestIsAdminSubdirIgnored(t *testing.T) {
t.Error("subdir .zddc admins entry was honored — that is a privilege-escalation hole") t.Error("subdir .zddc admins entry was honored — that is a privilege-escalation hole")
} }
} }
// 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, tc.email); 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, tc.email); got != tc.want {
t.Errorf("CanEditZddc(dir=%q, email=%q) = %v, want %v",
tc.dir, tc.email, got, tc.want)
}
})
}
}

View file

@ -18,9 +18,15 @@ type ACLRules struct {
// .zddc files have their Admins entry ignored by IsAdmin so that someone who // .zddc files have their Admins entry ignored by IsAdmin so that someone who
// can write into a subtree cannot grant themselves admin access. ACL on the // can write into a subtree cannot grant themselves admin access. ACL on the
// other hand cascades — see EffectivePolicy / AllowedWithChain. // other hand cascades — see EffectivePolicy / AllowedWithChain.
//
// Title is read only from per-project .zddc files (the file directly inside
// each project root) by ServeProjectList; it surfaces a human-readable name
// for the project on the landing-page picker. Optional — projects without a
// title fall back to displaying the directory name.
type ZddcFile struct { type ZddcFile struct {
ACL ACLRules `yaml:"acl"` ACL ACLRules `yaml:"acl"`
Admins []string `yaml:"admins"` Admins []string `yaml:"admins"`
Title string `yaml:"title"`
} }
// ParseFile reads and parses a .zddc YAML file. // ParseFile reads and parses a .zddc YAML file.

View file

@ -0,0 +1,49 @@
package zddc
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanZddcFiles walks fsRoot and returns every directory that contains a
// .zddc file, sorted by path. Reserved-prefix directories ('.', '_') are
// pruned from the walk — they hide internal/scaffolding state from the
// project picker and should likewise not surface in the editor's tree
// view. fsRoot itself is not subject to the prefix rule (it is the root).
//
// The returned paths are absolute (rooted under fsRoot). On any walk
// error the partial result accumulated so far is returned alongside the
// error so callers can degrade gracefully.
func ScanZddcFiles(fsRoot string) ([]string, error) {
fsRoot = filepath.Clean(fsRoot)
var dirs []string
err := filepath.WalkDir(fsRoot, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
// A permission error on a single subtree shouldn't kill the walk.
if os.IsPermission(walkErr) {
return nil
}
return walkErr
}
if !d.IsDir() {
return nil
}
// Prune reserved-prefix subtrees, but never the root itself.
if path != fsRoot {
name := d.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
return filepath.SkipDir
}
}
// Does this directory contain a .zddc file?
if _, err := os.Stat(filepath.Join(path, ".zddc")); err == nil {
dirs = append(dirs, path)
}
return nil
})
sort.Strings(dirs)
return dirs, err
}

View file

@ -0,0 +1,78 @@
package zddc
import (
"fmt"
"strings"
)
// ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or
// patterns with spaces); we want explicit rejection at write time so a
// human typo shows up as a 400 instead of "your rule mysteriously does
// nothing".
//
// Rules:
// - non-empty after trimming surrounding whitespace
// - no internal whitespace
// - at most one '@'
// - if an '@' is present, both local and domain parts are non-empty
// - the bare token "@" is rejected (covered by the non-empty parts rule)
func ValidatePattern(pattern string) error {
if pattern == "" {
return fmt.Errorf("pattern is empty")
}
if strings.TrimSpace(pattern) != pattern {
return fmt.Errorf("pattern has leading or trailing whitespace")
}
if strings.ContainsAny(pattern, " \t\n\r") {
return fmt.Errorf("pattern contains whitespace")
}
at := strings.Count(pattern, "@")
if at > 1 {
return fmt.Errorf("pattern contains more than one '@'")
}
if at == 1 {
parts := strings.SplitN(pattern, "@", 2)
if parts[0] == "" {
return fmt.Errorf("pattern has empty local part before '@'")
}
if parts[1] == "" {
return fmt.Errorf("pattern has empty domain part after '@'")
}
}
return nil
}
// ValidateFile returns a slice of (field, message) errors for any
// malformed entry in zf. An empty slice means the file is acceptable to
// write. The returned errors include enough field context that the editor
// can render them next to the offending row.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError
check := func(field string, vals []string) {
for i, v := range vals {
if err := ValidatePattern(v); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("%s[%d]", field, i),
Message: err.Error(),
})
}
}
}
check("acl.allow", zf.ACL.Allow)
check("acl.deny", zf.ACL.Deny)
check("admins", zf.Admins)
if len(zf.Title) > 200 {
errs = append(errs, FieldError{
Field: "title",
Message: "title exceeds 200 characters",
})
}
return errs
}

View file

@ -0,0 +1,77 @@
package zddc
import "testing"
func TestValidatePattern(t *testing.T) {
cases := []struct {
pattern string
ok bool
}{
{"alice@example.com", true},
{"*@example.com", true},
{"alice@*", true},
{"*", true},
{"", false},
{" alice@example.com", false},
{"alice@example.com ", false},
{"alice @example.com", false},
{"alice@ex ample.com", false},
{"alice@@example.com", false},
{"@example.com", false},
{"alice@", false},
{"@", false},
}
for _, tc := range cases {
t.Run(tc.pattern, func(t *testing.T) {
err := ValidatePattern(tc.pattern)
if tc.ok && err != nil {
t.Errorf("ValidatePattern(%q) = %v, want nil", tc.pattern, err)
}
if !tc.ok && err == nil {
t.Errorf("ValidatePattern(%q) = nil, want error", tc.pattern)
}
})
}
}
func TestValidateFile(t *testing.T) {
zf := ZddcFile{
Title: "ok",
ACL: ACLRules{Allow: []string{"good@example.com", "@bad"}, Deny: []string{"two@@ats"}},
Admins: []string{"@nobody"},
}
errs := ValidateFile(zf)
// expect 3 errors
if len(errs) != 3 {
t.Fatalf("got %d errors, want 3: %+v", len(errs), errs)
}
wantFields := map[string]bool{
"acl.allow[1]": false,
"acl.deny[0]": false,
"admins[0]": false,
}
for _, e := range errs {
if _, ok := wantFields[e.Field]; !ok {
t.Errorf("unexpected error field: %q", e.Field)
continue
}
wantFields[e.Field] = true
}
for f, seen := range wantFields {
if !seen {
t.Errorf("missing error for field %q", f)
}
}
}
func TestValidateFileTitleLength(t *testing.T) {
long := make([]byte, 201)
for i := range long {
long[i] = 'a'
}
zf := ZddcFile{Title: string(long)}
errs := ValidateFile(zf)
if len(errs) != 1 || errs[0].Field != "title" {
t.Fatalf("expected one title-length error, got %+v", errs)
}
}

View file

@ -0,0 +1,86 @@
package zddc
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// WriteFile atomically writes zf as YAML to <dirPath>/.zddc.
//
// The YAML round-trips through Marshal then Unmarshal as a sanity check —
// this catches struct-encoding bugs before they hit disk and ensures the
// file we produce is parseable by ParseFile (which is what every reader
// uses). On any failure the original file is untouched.
//
// Atomicity: the encoded bytes are written to a sibling temp file, fsync'd,
// and renamed onto the target. The cache for dirPath (and descendants) is
// invalidated after the rename so the next EffectivePolicy call reads
// fresh content.
func WriteFile(dirPath string, zf ZddcFile) error {
dirPath = filepath.Clean(dirPath)
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("ensure dir: %w", err)
}
data, err := yaml.Marshal(&zf)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
// Sanity round-trip: re-parse what we just produced. If this fails the
// in-memory struct does not survive a write/read cycle and we should
// abort before touching disk.
var probe ZddcFile
if err := yaml.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("round-trip parse: %w", err)
}
target := filepath.Join(dirPath, ".zddc")
tmp, err := os.CreateTemp(dirPath, ".zddc.*.tmp")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpPath := tmp.Name()
// Best-effort cleanup if anything below fails.
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("fsync temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("chmod temp: %w", err)
}
if err := os.Rename(tmpPath, target); err != nil {
return fmt.Errorf("rename: %w", err)
}
InvalidateCache(dirPath)
return nil
}
// DeleteFile removes <dirPath>/.zddc. Returns nil if the file does not exist.
// Cache invalidation runs unconditionally so any in-memory copy of an old
// chain is dropped.
func DeleteFile(dirPath string) error {
dirPath = filepath.Clean(dirPath)
target := filepath.Join(dirPath, ".zddc")
err := os.Remove(target)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove: %w", err)
}
InvalidateCache(dirPath)
return nil
}

View file

@ -0,0 +1,145 @@
package zddc
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileRoundTrip(t *testing.T) {
root := t.TempDir()
in := ZddcFile{
Title: "Greenfield Substation",
ACL: ACLRules{
Allow: []string{"*@varasys.io"},
Deny: []string{"intern@varasys.io"},
},
Admins: []string{"alice@varasys.io"},
}
if err := WriteFile(root, in); err != nil {
t.Fatalf("WriteFile: %v", err)
}
out, err := ParseFile(filepath.Join(root, ".zddc"))
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if out.Title != in.Title {
t.Errorf("Title = %q, want %q", out.Title, in.Title)
}
if len(out.ACL.Allow) != 1 || out.ACL.Allow[0] != in.ACL.Allow[0] {
t.Errorf("ACL.Allow = %v, want %v", out.ACL.Allow, in.ACL.Allow)
}
if len(out.Admins) != 1 || out.Admins[0] != "alice@varasys.io" {
t.Errorf("Admins = %v, want [alice@varasys.io]", out.Admins)
}
}
func TestWriteFileAtomicNoTempLeftBehind(t *testing.T) {
root := t.TempDir()
if err := WriteFile(root, ZddcFile{Title: "a"}); err != nil {
t.Fatalf("WriteFile: %v", err)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Errorf("temp file left behind: %s", e.Name())
}
}
}
func TestWriteFileInvalidatesCache(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "project")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Prime the cache with an empty chain.
if _, err := EffectivePolicy(root, sub); err != nil {
t.Fatalf("prime cache: %v", err)
}
if err := WriteFile(sub, ZddcFile{
ACL: ACLRules{Allow: []string{"alice@example.com"}},
}); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// After write the cache must reflect the new content.
chain, err := EffectivePolicy(root, sub)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if !chain.HasAnyFile {
t.Fatal("HasAnyFile = false; cache not invalidated")
}
leaf := chain.Levels[len(chain.Levels)-1]
if len(leaf.ACL.Allow) != 1 || leaf.ACL.Allow[0] != "alice@example.com" {
t.Errorf("leaf allow = %v, want [alice@example.com]", leaf.ACL.Allow)
}
}
func TestWriteFileOverwritePreservesOriginalOnFailure(t *testing.T) {
// We can't easily simulate a rename failure portably, but we can at
// least confirm that the happy-path overwrite produces the new
// content (so the rename worked) and that the previous content is
// gone (no merge or append).
root := t.TempDir()
if err := WriteFile(root, ZddcFile{Title: "first"}); err != nil {
t.Fatalf("first write: %v", err)
}
if err := WriteFile(root, ZddcFile{Title: "second"}); err != nil {
t.Fatalf("second write: %v", err)
}
out, err := ParseFile(filepath.Join(root, ".zddc"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if out.Title != "second" {
t.Errorf("Title = %q, want %q", out.Title, "second")
}
}
func TestDeleteFile(t *testing.T) {
root := t.TempDir()
if err := WriteFile(root, ZddcFile{Title: "a"}); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".zddc")); err != nil {
t.Fatalf("file should exist before delete: %v", err)
}
// Prime the cache so we can verify invalidation post-delete.
if _, err := EffectivePolicy(root, root); err != nil {
t.Fatalf("prime: %v", err)
}
if err := DeleteFile(root); err != nil {
t.Fatalf("DeleteFile: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".zddc")); !os.IsNotExist(err) {
t.Errorf("file should be gone after delete: err=%v", err)
}
chain, err := EffectivePolicy(root, root)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if chain.HasAnyFile {
t.Error("HasAnyFile should be false after delete; cache not invalidated")
}
}
func TestDeleteFileMissing(t *testing.T) {
root := t.TempDir()
// No .zddc has been written; delete must be a no-op.
if err := DeleteFile(root); err != nil {
t.Errorf("DeleteFile on missing file = %v, want nil", err)
}
}