From cd05cd63664cb1c131606f401400fee8596567aa Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 12:40:47 -0500 Subject: [PATCH] docs+server: document the .zddc bootstrap config + warn at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh ZDDC deployment grants no access to anyone until an operator populates the root .zddc (admins) and per-project .zddc files (role members). Until now this was only documented in comments inside the embedded defaults.zddc.yaml, surfaced via `zddc-server show-defaults` — operators wiring up a fresh master had no obvious doc to follow and no startup signal when the bootstrap was missing or empty. - README.md: new "## Deploy: bootstrap config" section between Tools and File-naming convention. Two canonical examples (root admin-only, per-project role members), schema essentials (verb bits, principal forms, admins-only-at-root), and the acl: { allow: [...] } footgun that silently drops grants. - AGENTS.md: new "### Bootstrap config (REQUIRED — unlocks the server)" subsection at the top of ## zddc-server. Same content as README but with file:line citations into zddc/internal/zddc/file.go for the schema source of truth. - zddc-server: new warnIfNoBootstrap fires a slog.Warn at startup when the root .zddc grants nobody anything (no admins, no acl.permissions, no role members). Master mode only; skipped under --no-auth. - config validator's existing no-root-.zddc fail-fast error message now also points at the new README + AGENTS sections so all three signals (fail-fast, runtime warning, docs) converge. Smoke-tested all paths: empty root + default (fail-fast), empty root + --insecure (file-missing warn), admins-only / perms-only / role-members -only (silent), title-only and acl.allow footgun (both warn), --no-auth (suppressed). All existing go tests pass. Follow-up (manual, separate repo): add an analogous section to ~/src/zddc-website/reference.html. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 40 +++++++++++++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++++ zddc/cmd/zddc-server/main.go | 46 ++++++++++++++++++++++++++++++++++ zddc/internal/config/config.go | 3 ++- 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index de76885..0c43155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -482,6 +482,46 @@ This is a guideline, not a rule. Revisit per-feature: when v1+ form-spec adds `$ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives. +### Bootstrap config (REQUIRED — unlocks the server) + +zddc-server grants no access to anyone until two operator files are populated. The embedded `defaults.zddc.yaml` ships with empty role members and references those roles throughout its cascade, so a fresh deployment refuses every request until the operator opts in. `zddc-server` logs a startup warning (see `warnIfNoBootstrap` in `zddc/cmd/zddc-server/main.go`) when the root `.zddc` grants nobody anything — skipped under `--no-auth`. + +**Root `/.zddc`** — at minimum, declare an admin: + +```yaml +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. + +**Per-project `/.zddc`** — populate role members: + +```yaml +title: "Project Phoenix" +roles: + document_controller: + members: + - dc1@burnsmcd.com + project_team: + members: + - alice@burnsmcd.com + - '*@acme.com' +``` + +The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin on `working/`/`staging/`/`reviewing/`). Populating role members lights all of that up. + +**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`): + +- `acl: { permissions: { : }, inherit: ? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works. +- Bits: any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny. +- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`). +- `roles: { : { members: [...], reset: ? } }` — members union across the cascade unless `reset: true`. +- `admins: [, ...]` — root only; sudo-style elevation per request. +- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker. + +Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.). + ### Build zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag). diff --git a/README.md b/README.md index 7114ae0..9a2b832 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,47 @@ The name "Zero Day Document Control" comes from the convention itself — adopt Each tool is published in three channels (stable, beta, alpha) as static files served from . **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive//mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `/_app/` — or drop a real `.html` file at any path. +## Deploy: bootstrap config + +> **A fresh `zddc-server` deployment grants no access to anyone until two config files are populated.** Without them, the server runs but every request returns 403. The embedded `defaults.zddc.yaml` ships with empty role members so deployments must opt-in to authorize anyone. + +**Step 1.** At the master root, create `/.zddc` (i.e. `/.zddc`) naming at least one admin: + +```yaml +admins: + - cwitt@burnsmcd.com +``` + +`admins:` is honored only at the root file. Admins behave as normal users by default and elevate per-request via the `zddc-elevate=1` cookie (header toggle in every tool) or implicitly when authenticating with a bearer token. + +**Step 2.** In each project, create `/.zddc` to populate the `document_controller` and `project_team` role members: + +```yaml +title: "Project Phoenix" +roles: + document_controller: + members: + - dc1@burnsmcd.com + project_team: + members: + - alice@burnsmcd.com + - '*@acme.com' # external counterparty (glob) +``` + +That's it. The embedded cascade does the rest — `project_team` gets read across the project; `document_controller` gets write/create authority on the archive subtree, WORM filing rights on `received/issued`, and subtree-admin of `working/`/`staging/`/`reviewing/`. + +**Common footgun.** `acl: { allow: [...] }` is silently ignored (the YAML parses, but `ACLRules` only reads `permissions:`). The correct shape is: + +```yaml +acl: + permissions: + '': +``` + +Bits are any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny. Principals are emails, globs like `*@domain.com`, or role names (anything without an `@`). + +`zddc-server` prints a startup warning when the root `.zddc` grants nobody anything — watch for it on first boot. For the full schema, run `zddc-server show-defaults` (dumps the embedded `defaults.zddc.yaml` with annotated comments). + ## File-naming convention The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at . diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 13da302..5757fc4 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -218,6 +218,12 @@ func main() { "cache_ttl", cfg.OPACacheTTL, "no_auth", cfg.NoAuth) + // Bootstrap sanity: warn loudly (but don't fail) when the root .zddc + // grants nobody anything. Embedded defaults.zddc.yaml ships with empty + // role members, so a fresh deployment refuses every request until the + // operator populates the file. + warnIfNoBootstrap(cfg) + // Token store: bearer-token issuance and validation. // Persists under /.zddc.d/tokens/ — already excluded // from public listings (fs.ListDirectory dot-prefix filter) and @@ -546,6 +552,46 @@ func setupApps(cfg config.Config) (*apps.Server, error) { return apps.NewServer(cfg.Root, cache, fetcher, version), nil } +// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants +// nobody anything — the embedded defaults.zddc.yaml ships with empty role +// members, so a deployment without operator-populated admins / acl +// permissions / role members refuses every request. Skipped under +// --no-auth (auth disabled; warning would be redundant). Per-project +// .zddc files may legitimately carry all grants, so the warning text +// tells the operator they can ignore it in that case. +// +// Master-mode only — the bootstrap concept doesn't apply in client +// (proxy/cache/mirror) mode, where cfg.Root is the cache directory. +func warnIfNoBootstrap(cfg config.Config) { + if cfg.NoAuth { + return + } + rootPath := filepath.Join(cfg.Root, ".zddc") + rootZddc, err := zddc.ParseFile(rootPath) + if err != nil { + slog.Warn("root .zddc not present or unreadable; ZDDC will refuse every request until you create it. "+ + "See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.", + "path", rootPath, "err", err) + return + } + hasAdmin := len(rootZddc.Admins) > 0 + hasPerm := len(rootZddc.ACL.Permissions) > 0 + hasRoleMembers := false + for _, role := range rootZddc.Roles { + if len(role.Members) > 0 { + hasRoleMembers = true + break + } + } + if !hasAdmin && !hasPerm && !hasRoleMembers { + slog.Warn("root .zddc grants nobody anything (no admins, no acl.permissions, no role members). "+ + "ZDDC will refuse every request until you populate it. "+ + "If you intentionally grant only at per-project levels, you can ignore this. "+ + "See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.", + "path", rootPath) + } +} + // printVersions writes the binary version + the build label of every app // embedded into the binary. Called by --version and reused for the // startup log line. diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 54c6da5..c2f8668 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -264,7 +264,8 @@ func Load(args []string) (Config, error) { return Config{}, fmt.Errorf( "no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+ "Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+ - "or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment", + "or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment. "+ + "See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'", cfg.Root) } else if err != nil { return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)