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:
parent
cf4101b9e4
commit
e44ccc3500
17 changed files with 2030 additions and 29 deletions
|
|
@ -18,25 +18,34 @@ import (
|
|||
// resolution. If a real conflict ever shows up, make this a config value.
|
||||
const AdminPathPrefix = "/.admin"
|
||||
|
||||
// ServeAdmin is the entry point for /.admin/* routes. It enforces the
|
||||
// admins-allowlist gate (returns 404 on non-admin so the existence of the
|
||||
// admin page is not leaked) and dispatches to a sub-handler.
|
||||
// ServeAdmin is the entry point for /.admin/* routes. The /whoami,
|
||||
// /config, /logs, and dashboard sub-routes are super-admin-only (gated
|
||||
// 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
|
||||
// the Admins list of <cfg.Root>/.zddc. See zddc.IsAdmin.
|
||||
// /.admin/zddc/* — the .zddc editor — is reachable to ANY subtree-admin
|
||||
// (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) {
|
||||
email := EmailFromContext(r)
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Trim the prefix, keep a leading "/" for sub-route matching.
|
||||
sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix)
|
||||
if 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 {
|
||||
case "/", "":
|
||||
serveAdminDashboard(w, r)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,14 @@ import (
|
|||
)
|
||||
|
||||
// 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 {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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{
|
||||
Name: name,
|
||||
URL: "/" + name + "/",
|
||||
Name: name,
|
||||
URL: "/" + name + "/",
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
zddc/internal/handler/zddc_assets.go
Normal file
58
zddc/internal/handler/zddc_assets.go
Normal 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)
|
||||
}
|
||||
}
|
||||
345
zddc/internal/handler/zddceditor.go
Normal file
345
zddc/internal/handler/zddceditor.go
Normal 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 }}/">← admin</a> /
|
||||
<a href="{{ .AdminPathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} / <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>
|
||||
`))
|
||||
349
zddc/internal/handler/zddchandler.go
Normal file
349
zddc/internal/handler/zddchandler.go
Normal 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)
|
||||
}
|
||||
|
||||
362
zddc/internal/handler/zddchandler_test.go
Normal file
362
zddc/internal/handler/zddchandler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@ import "strings"
|
|||
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
||||
// deny checked first
|
||||
for _, pattern := range level.ACL.Deny {
|
||||
if matchesPattern(pattern, email) {
|
||||
if MatchesPattern(pattern, email) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
for _, pattern := range level.ACL.Allow {
|
||||
if matchesPattern(pattern, email) {
|
||||
if MatchesPattern(pattern, email) {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ func AllowedWithChain(chain PolicyChain, email string) bool {
|
|||
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,
|
||||
// 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@example.com" matches exactly
|
||||
// - "*" 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)
|
||||
if pattern == email {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -81,15 +81,15 @@ func TestMatchesPattern(t *testing.T) {
|
|||
{"alice@example.com", "alice", false},
|
||||
|
||||
// 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.
|
||||
// Document the actual behavior in the test.
|
||||
{"*", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+"|"+tc.email, func(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import "path/filepath"
|
|||
|
||||
// IsAdmin reports whether email is listed in the admins entry of the ROOT
|
||||
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
|
||||
// deliberately ignored — admin grants are a server-wide role, and honoring
|
||||
// them in subtrees would let anyone with subtree write access elevate
|
||||
// themselves.
|
||||
// deliberately ignored by this function — it gates the server-wide debug
|
||||
// admin role (/.admin/{whoami,config,logs}) which only the bootstrap
|
||||
// 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
|
||||
// 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.
|
||||
func IsAdmin(fsRoot, email string) bool {
|
||||
if email == "" {
|
||||
|
|
@ -20,9 +23,82 @@ func IsAdmin(fsRoot, email string) bool {
|
|||
return false
|
||||
}
|
||||
for _, pattern := range zf.Admins {
|
||||
if matchesPattern(pattern, email) {
|
||||
if MatchesPattern(pattern, email) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,9 @@ func TestIsAdmin(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestIsAdminSubdirIgnored documents that admins entries in subdirectory
|
||||
// .zddc files are NOT honored — only the root .zddc grants admin. Otherwise
|
||||
// anyone with subtree write access could elevate themselves.
|
||||
// .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")
|
||||
|
|
@ -106,3 +107,298 @@ func TestIsAdminSubdirIgnored(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ type ACLRules struct {
|
|||
// .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
|
||||
// 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 {
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Title string `yaml:"title"`
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a .zddc YAML file.
|
||||
|
|
|
|||
49
zddc/internal/zddc/scan.go
Normal file
49
zddc/internal/zddc/scan.go
Normal 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
|
||||
}
|
||||
78
zddc/internal/zddc/validate.go
Normal file
78
zddc/internal/zddc/validate.go
Normal 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
|
||||
}
|
||||
77
zddc/internal/zddc/validate_test.go
Normal file
77
zddc/internal/zddc/validate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
86
zddc/internal/zddc/writer.go
Normal file
86
zddc/internal/zddc/writer.go
Normal 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
|
||||
}
|
||||
145
zddc/internal/zddc/writer_test.go
Normal file
145
zddc/internal/zddc/writer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue