docs: update the admin/elevation model to standing config-edit + additive sudo

CLAUDE.md/AGENTS.md/ARCHITECTURE.md described the old "elevation gates
.zddc edit" model. Rewrite the elevation sections to the current model:
config-edit is a STANDING permission (IsConfigEditor — subtree admin or
`a` verb, no toggle, VerbA granted above the WORM clamp); elevation is
purely additive (IsActiveAdmin = admin AND Elevated, single bypass site,
guards WORM/destructive/out-of-scope only); the elevate cookie is now a
per-page session cookie armed by the on-page bottom-right toggle; the
.zddc.zip bundle is visible+editable to config-editors of its dir (not
wide-read); .zddc.d secrets stay locked; config is transparent via
read-ACL'd ServeZddcFile. Drops stale references (CanEditZddc, Max-Age=1800,
header-toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 17:37:48 -05:00
parent 9b20e4451f
commit 18d3aaebf0
3 changed files with 16 additions and 11 deletions

View file

@ -293,7 +293,7 @@ To override a tool's HTML (local-only — no fetch, no channels/versions):
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
2. Add an `<app>.html` member to the site bundle `<ZDDC_ROOT>/.zddc.zip` (a local zip read server-side via `internal/zipfs`; overrides that tool everywhere, and lets you add new `<name>.html` tools). To route a *different* tool at a path, change `default_tool` / `dir_tool` / `available_tools`.
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone; the server reads its members from the filesystem internally.
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` (bare, `/`-listing, or `/<member>`) is **404 except for a standing config-editor over the bundle's directory** (a subtree admin / `a`-verb holder — no elevation required; `configEditorForBundle` in `cmd/zddc-server/main.go`), who may browse and edit it in place. It is deliberately NOT wide-readable even to plain readers, because one file packs many subtrees' policy — per-level transparency is `ServeZddcFile`'s job. The server reads its members from the filesystem internally regardless.
Operators audit by reading the `X-ZDDC-Source` response header: `bundle:<app>.html` / `embedded:<app>@<build>` (an on-disk override is served by the static handler with its own headers).
@ -485,7 +485,7 @@ admins:
- cwitt@burnsmcd.com
```
`admins:` is honored only at the root (subdir admins are read but ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). Admins are sudo-style — powers gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation.
`admins:` at the **root** confers super-admin (`IsAdmin`, root-only — subdir `admins:` are ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). `admins:` at **any level** confers *subtree* admin over that level and below (`IsSubtreeAdmin` / `IsConfigEditor`). Config-edit (editing `.zddc`/roles you administer) is **standing** — no elevation. Only the override powers (WORM bypass, recursive delete, rearrange, out-of-scope) gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation. See "Admin authority: standing config-edit + additive elevation".
**Per-project `<project>/.zddc`** — populate role members:
@ -707,17 +707,17 @@ The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segm
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Admin elevation (sudo-style)
### Admin authority: standing config-edit + additive elevation
Admins are treated as normal users by default; admin escape hatches (WORM bypass, auto-own takeover, `.zddc` edit authority, profile admin scaffolds) require an explicit per-request opt-in. The toggle lives in every tool's header (left of the theme button) and writes a `zddc-elevate=1` cookie (Max-Age=1800, SameSite=Lax) — 30-minute sudo window before it auto-expires.
Two distinct layers — keep them straight:
Server-side the model is `zddc.Principal{Email, Elevated}`. `ACLMiddleware` builds it once per request and stashes it in context; `IsAdmin` / `IsSubtreeAdmin` / `CanEditZddc` take a `Principal` parameter rather than a bare email. That signature change is the enforcement mechanism — the compiler tells you when an admin call site doesn't thread elevation, so a "forgot to gate this" mistake doesn't compile. `PrincipalFromContext(r)` is the one-call-per-site bundling helper.
**Standing config-edit (no toggle).** Editing configuration is a *standing* permission, not a sudo escape hatch. `zddc.IsConfigEditor(chain, email)` — being a subtree admin (any `admins:` grant on the cascade) OR holding the `a` verb — lets a principal read AND edit the `.zddc` / `.zddc.zip` / role definitions of the subtrees they administer *without elevating*. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis **above the WORM clamp**: config is not WORM-protected data, and `VerbA` only ever authorises config mutation (never write/delete/create of records). Plain `.zddc` reads are gated by directory read-ACL (`ServeZddcFile`), so config is transparent to anyone who can read the path. The blast radius of config-edit is exactly "this subtree and down" — authority cascades downward only (editing `/A/B/.zddc` needs admin over `/A/B`, which never appears in `/A`'s chain), and `ActionAdmin` requires `VerbA`, so a plain `w`/`c` grant can't write a self-promoting `.zddc`.
Bearer tokens are **implicitly elevated** — CLI clients and the mirror process can't toggle a cookie, and their authority is the bearer's full grant by design. Browser sessions elevate only when the user opts in.
**Elevation is the additive sudo layer.** It unlocks only "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, auto-own takeover, acting outside your admin scope, profile admin scaffolds. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the **single bypass site** in the decider. Carried in a `zddc-elevate=1` **session** cookie (no Max-Age, SameSite=Lax; cleared on `pagehide` so admin mode is scoped to the page you armed it on). Armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false` (honored per-request server-side too), or implicitly for bearer tokens (CLI/mirror can't toggle a cookie; their authority is the bearer's full grant). `shared/elevation.js` applies state in place (no reload — a reload would race the pagehide-clear) and emits a `zddc:elevationchange` event so SPAs (browse) re-fetch verbs.
`/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?") so the header toggle can render itself for an un-elevated admin who hasn't opted in yet. The access log captures `elevated=<true|false>` per request for forensics.
Server-side `zddc.Principal{Email, Elevated}` is built once per request by `ACLMiddleware`; `IsAdmin` / `IsSubtreeAdmin` take a `Principal` and stay elevation-gated (they guard the overrides), while `IsConfigEditor` is ungated (the standing config-edit path). `PrincipalFromContext(r)` is the bundling helper. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?"); the access log captures `elevated=<true|false>` per request.
Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated functions), `zddc/internal/handler/middleware.go` (cookie/bearer → ElevatedKey context value), `shared/elevation.{js,css}` (header toggle UI, concat'd into every tool's bundle).
Implementation: `zddc/internal/zddc/admin.go` (Principal + `IsConfigEditor`/`IsSubtreeAdmin`/`IsAdmin`), `zddc/internal/policy/policy.go` (decider: `IsActiveAdmin` bypass + standing `VerbA` branch above the WORM clamp), `zddc/internal/handler/middleware.go` (cookie/bearer/`?admin` → ElevatedKey), `shared/elevation.{js,css}` (on-page toggle + ephemeral cookie, concat'd into every tool).
### Release tagging

View file

@ -675,7 +675,9 @@ Five permission verbs gate every read and write:
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
- **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records.
- **The unconditional `rwcda` + WORM/cascade bypass requires elevation:** `IsActiveAdmin = admin-on-chain AND Elevated` is the single bypass site. Un-elevated, an admin is a config-editor, not a WORM-bypassing superuser.
#### Canonical folders, URL routing & the `.zddc` cascade
@ -713,7 +715,7 @@ The schema keys that drive built-in behavior:
**Subtree download.** `GET /some/dir/?zip=1` (the query form works on both `/dir` and `/dir/`) streams an `application/zip` of every readable file under that directory, recursively — `Content-Disposition: attachment; filename="<dir>.zip"`. It's `handler.ServeSubtreeZip`: a `filepath.WalkDir` that ACL-gates each file by the `.zddc` chain of its containing directory (the same per-directory decision cache `serveArchiveListing` uses), skips hidden entries (`.`/`_`-prefixed: `.zddc`, `_template`, `_app`), and adds any `.zip` *file* it meets as opaque bytes (it does **not** recurse into it — that's the navigable-surface above, a different feature). The response is streamed straight onto the `ResponseWriter` (`zip.Store` for already-compressed extensions, `zip.Deflate` otherwise), so a fully-ACL-denied or empty subtree yields a valid empty zip rather than a 403 (a stream can't change status after the headers go out). The browse tool's toolbar **Download (zip)** button hits this for the directory in view in server mode; offline (file://) it walks the picked folder itself with JSZip (with a `confirm()` above ~2000 files / ~500 MB, since the whole tree is buffered in browser memory).
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. the embedded default tree puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Two carve-outs: an **elevated** admin (root / subtree) bypasses the clamp entirely — the escape hatch for mis-filed documents — and a **standing** config-editor keeps `a` (so a subtree admin can edit the `.zddc` that *governs* a WORM zone without elevating; that grants only config mutation, never record write/delete). the embedded default tree puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** the embedded default tree references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):

View file

@ -88,6 +88,9 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
- **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these.
- **Admin elevation is sudo-style.** Admins behave as normal users by default; opting into admin powers is per-request and gated by the `zddc-elevate=1` cookie (Max-Age=1800, set by the header toggle in every tool). Server-side: `zddc.Principal{Email, Elevated}` is built once per request by `handler.ACLMiddleware` and threaded into `IsAdmin`/`IsSubtreeAdmin`/`CanEditZddc` — the compiler enforces the gate at every admin call site (no easy "forgot to check elevation" mistake). Bearer-token requests are implicitly elevated since CLI clients can't toggle a cookie; browser sessions elevate only when the user clicks the header checkbox. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have admin authority anywhere?") so the header toggle can decide whether to render itself for an un-elevated admin. The access-log captures the `elevated` flag per request for forensics.
- **Admin authority is layered: standing config-edit + additive sudo overrides.** Two distinct things — don't conflate them:
- **Config-edit is STANDING (no toggle).** A subtree admin (named in any `admins:` on the cascade) or anyone holding the `a` verb may *read and edit* the `.zddc` / `.zddc.zip` / role definitions of subtrees they administer without elevating — `zddc.IsConfigEditor(chain, email)`. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis *above* the WORM clamp (config isn't WORM-protected data, and `VerbA` only ever authorises config mutation, never write/delete of records). "Admin of X = owns X's policy," bounded to that subtree (authority cascades down only, never up). Plain `.zddc` reads are governed by directory read-ACL (`ServeZddcFile`), so **config is transparent** to anyone who can read the path.
- **Elevation is the sudo escape hatch — purely ADDITIVE.** It only unlocks "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, acting outside your admin scope. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the single bypass site in the decider; `IsAdmin`/`IsSubtreeAdmin` stay elevation-gated (they guard the overrides). Carried in the `zddc-elevate=1` **session** cookie (no Max-Age; cleared on `pagehide`, so admin mode is per-page), armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false`, or implicitly for bearer tokens. `shared/elevation.js` applies it in place + emits `zddc:elevationchange` (browse re-fetches verbs); `handler.ACLMiddleware` builds `zddc.Principal{Email, Elevated}` per request. `/.profile/access` exposes `can_elevate`; the access-log captures `elevated` per request.
- **Secrets stay locked:** `.zddc.d/` (bearer tokens, access logs) is reserved regardless of read-ACL. The `.zddc.zip` bundle is visible+editable to config-editors of its directory (not wide-read — one file packs many subtrees' policy).
- **Worktrees live at `~/src/zddc-<branch>`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using.
- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only.