diff --git a/AGENTS.md b/AGENTS.md index 94a4940..3606c4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -489,9 +489,18 @@ roles: members: - alice@burnsmcd.com - '*@acme.com' + observer: + members: + - auditor@regulator.gov ``` -The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive//` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up. +The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive//` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`. + +Pick a role per persona: + +- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. +- `project_team` — day-to-day contributor. Read across the project; full control of their own `archive//working//` home via auto-own with a fenced `.zddc` (`inherit: false`). +- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content. **Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`): diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ac8f0b..8920dd1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -713,7 +713,13 @@ The schema keys that drive built-in behavior: **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. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive//{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.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive//` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `archive//working//` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule). +**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them): + +- `document_controller` — read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive//` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow. Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles. +- `project_team` — read-only across the project; their own `archive//working//` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule. +- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content. + +The role invariants (verb sets at each canonical path, subtree-admin scope) are locked down in `zddc/internal/zddc/standardroles_test.go`. New roles, when added, should ship with a parallel test in that file. ### File API (authenticated CRUD) diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index d71604f..936b061 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -22,7 +22,7 @@ acl: # ── Standard roles ───────────────────────────────────────────────────────── # -# Two roles ship empty (no members) — a fresh deployment grants +# Three roles ship empty (no members) — a fresh deployment grants # nothing until an operator populates them. They're referenced by the # project-scoped grants in paths: below. # @@ -40,18 +40,31 @@ acl: # per-party working/ + staging/ + reviewing/ so they can stand up # and manage drafting/transmittal/review folders. They are NOT # subtree-admin of archive//, so the WORM constraint still -# binds them in received/issued. +# binds them in received/issued. Plan-Review approval is part of +# this role by design — there is no separate `approver` role; +# two-person sign-off, when needed, is expressed via per-folder +# `.zddc` overrides rather than baked-in roles. # # project_team — everyone working on a project. Read-only across # the project. Their own archive//working// home and # anything they create under incoming/ get a creator-owned auto- # own .zddc (rwcda) which wins via deepest-match, so "read-only # except what I own" falls out of the cascade with no special rule. +# +# observer — pure read-only across the project. Like project_team +# but with no auto-own home: an observer who somehow created a +# working// would still own it via auto-own (the mechanism +# is path-keyed, not role-keyed), but since observer lacks `c` +# anywhere, the situation doesn't arise in practice. Intended for +# auditors, regulators, and external read-only viewers who must +# not contribute content. roles: document_controller: members: [] project_team: members: [] + observer: + members: [] # Universal tool baseline. archive (record browser), browse (file # tree, hosts the in-place markdown editor), and landing (project @@ -123,17 +136,18 @@ available_tools: [archive, browse, landing] paths: # First segment under root is the project name; "*" matches any. "*": - # Project-scoped baseline ACL. project_team gets read across the - # project; document_controller gets read + overwrite-existing - # (so people can ask them to fix a stuck file). Neither gets - # `c` (create) at this level — that's granted only at the - # specific spots below (archive/, working/, staging/), so the - # doc controller can't make arbitrary folders. Grants here cap + # Project-scoped baseline ACL. project_team and observer get read + # across the project; document_controller gets read + overwrite- + # existing (so people can ask them to fix a stuck file). None of + # the three gets `c` (create) at this level — that's granted only + # at the specific spots below (archive/, working/, staging/), so + # the doc controller can't make arbitrary folders. Grants here cap # at deeper levels per deepest-match-wins, except where a deeper # .zddc restates a fuller grant for the same principal. acl: permissions: project_team: r + observer: r document_controller: rw paths: # ── Top-level virtual aggregators ─────────────────────────── diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index 39b0f0f..9ad827f 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -140,3 +140,69 @@ created_by: alice@example.com t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String()) } } + +// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the +// project-wide read-only role for auditors / regulators / external +// viewers. Unlike project_team, an observer must not contribute +// content anywhere: no create at archive/, no create at working/, +// no worm-create at received/issued, and not subtree-admin of +// anything. Read passes through WORM zones (worm: lists strip w/d/a +// but never r). +func TestStandardRoles_ObserverReadOnlyEverywhere(t *testing.T) { + resetCache() + root := t.TempDir() + writeZddc(t, root, `roles: + observer: + members: ["auditor@example.com"] +`) + obs := "auditor@example.com" + + mustVerbs := func(dir string, want string) { + t.Helper() + chain, err := EffectivePolicy(root, dir) + if err != nil { + t.Fatalf("EffectivePolicy(%q): %v", dir, err) + } + // Mirror InternalDecider.Allow's WORM-aware composition so the + // assertion covers received/issued correctly. + var got VerbSet + if g, inWorm := WormZoneGrant(chain, obs); inWorm { + got = (EffectiveVerbs(chain, obs) & VerbR) | (g & VerbsRC) + } else { + got = EffectiveVerbs(chain, obs) + } + if got.String() != want { + t.Errorf("observer verbs at %s = %q, want %q", dir[len(root):], got.String(), want) + } + } + + // Project level: read-only. + mustVerbs(filepath.Join(root, "Proj"), "r") + // A random subfolder under the project still read-only. + mustVerbs(filepath.Join(root, "Proj", "random-folder"), "r") + // archive/ — read-only (no create at the party-folder level). + mustVerbs(filepath.Join(root, "Proj", "archive"), "r") + // incoming/ — read-only (no create even though incoming/ has + // drop_target and auto_own; the cascade ACL still gates create). + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "r") + // In-flight lifecycle slots — read-only. + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "working"), "r") + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "staging"), "r") + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), "r") + // WORM zones — read passes through; no worm-create (observer is + // not in the worm: list). + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "r") + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "r") + + // Observer is not subtree-admin of anything in the project — even + // when notionally elevated, the role carries no admin grant. + if IsSubtreeAdmin(root, filepath.Join(root, "Proj"), Principal{Email: obs, Elevated: true}) { + t.Errorf("observer should NOT be subtree-admin of the project root") + } + if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: obs, Elevated: true}) { + t.Errorf("observer should NOT be subtree-admin of archive/") + } + if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: obs, Elevated: true}) { + t.Errorf("observer should NOT be subtree-admin of archive//") + } +}