feat(roles): add observer standard role

A third standard role for auditors, regulators, and external
read-only viewers. Like project_team it gets project-wide `r`, but
unlike project_team the role itself carries no `c` anywhere — so an
observer can't bring a working/<email>/ home into existence under
auto-own, even though the auto-own mechanism is path-keyed rather
than role-keyed.

Approver-by-design: the role audit explicitly rejects a separate
`approver` role. Plan-Review approval stays with document_controller;
two-person sign-off, when needed, is expressed via per-folder `.zddc`
overrides rather than baked-in roles. Comments in defaults.zddc.yaml
and ARCHITECTURE.md call this out so future role audits don't
reopen the question.

TestStandardRoles_ObserverReadOnlyEverywhere locks the invariants:
project-wide r, no c at archive/incoming/working/staging/reviewing,
WORM zones read-only (no worm-create), and not subtree-admin
anywhere even when notionally elevated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 07:59:44 -05:00
parent 59b5550872
commit fb50bb5ef6
4 changed files with 105 additions and 10 deletions

View file

@ -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/<party>/` 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/<party>/` 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/<party>/working/<email>/` 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`):

View file

@ -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/<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.** `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/<party>/` 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/<party>/working/<email>/` 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/<party>/` 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/<party>/working/<email>/` 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)

View file

@ -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/<party>/, 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/<party>/working/<email>/ 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/<email>/ 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 ───────────────────────────

View file

@ -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/<party>/")
}
}