docs+server: document the .zddc bootstrap config + warn at startup
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) <noreply@anthropic.com>
This commit is contained in:
parent
69878532b0
commit
cd05cd6366
4 changed files with 129 additions and 1 deletions
40
AGENTS.md
40
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.
|
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_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 `<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: { <principal>: <bits> }, inherit: <bool>? }` — 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: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
||||||
|
- `admins: [<email>, ...]` — 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
|
### 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).
|
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).
|
||||||
|
|
|
||||||
41
README.md
41
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 <https://zddc.varasys.io/releases/>. **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/<party>/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 `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
|
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **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/<party>/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 `<ZDDC_ROOT>/_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_ROOT>/.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 `<project>/.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:
|
||||||
|
'<principal>': <bits>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## File-naming convention
|
||||||
|
|
||||||
The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at <https://zddc.varasys.io/reference.html>.
|
The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at <https://zddc.varasys.io/reference.html>.
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,12 @@ func main() {
|
||||||
"cache_ttl", cfg.OPACacheTTL,
|
"cache_ttl", cfg.OPACacheTTL,
|
||||||
"no_auth", cfg.NoAuth)
|
"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.
|
// Token store: bearer-token issuance and validation.
|
||||||
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
|
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
|
||||||
// from public listings (fs.ListDirectory dot-prefix filter) and
|
// 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
|
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
|
// printVersions writes the binary version + the build label of every app
|
||||||
// embedded into the binary. Called by --version and reused for the
|
// embedded into the binary. Called by --version and reused for the
|
||||||
// startup log line.
|
// startup log line.
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,8 @@ func Load(args []string) (Config, error) {
|
||||||
return Config{}, fmt.Errorf(
|
return Config{}, fmt.Errorf(
|
||||||
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
|
"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]`) "+
|
"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)
|
cfg.Root)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
|
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue