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:
parent
59b5550872
commit
fb50bb5ef6
4 changed files with 105 additions and 10 deletions
11
AGENTS.md
11
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/<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`):
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>/")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue