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:
|
members:
|
||||||
- alice@burnsmcd.com
|
- alice@burnsmcd.com
|
||||||
- '*@acme.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`):
|
**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.
|
**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)
|
### File API (authenticated CRUD)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ acl:
|
||||||
|
|
||||||
# ── Standard roles ─────────────────────────────────────────────────────────
|
# ── 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
|
# nothing until an operator populates them. They're referenced by the
|
||||||
# project-scoped grants in paths: below.
|
# project-scoped grants in paths: below.
|
||||||
#
|
#
|
||||||
|
|
@ -40,18 +40,31 @@ acl:
|
||||||
# per-party working/ + staging/ + reviewing/ so they can stand up
|
# per-party working/ + staging/ + reviewing/ so they can stand up
|
||||||
# and manage drafting/transmittal/review folders. They are NOT
|
# and manage drafting/transmittal/review folders. They are NOT
|
||||||
# subtree-admin of archive/<party>/, so the WORM constraint still
|
# 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
|
# project_team — everyone working on a project. Read-only across
|
||||||
# the project. Their own archive/<party>/working/<email>/ home and
|
# the project. Their own archive/<party>/working/<email>/ home and
|
||||||
# anything they create under incoming/ get a creator-owned auto-
|
# anything they create under incoming/ get a creator-owned auto-
|
||||||
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
||||||
# except what I own" falls out of the cascade with no special rule.
|
# 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:
|
roles:
|
||||||
document_controller:
|
document_controller:
|
||||||
members: []
|
members: []
|
||||||
project_team:
|
project_team:
|
||||||
members: []
|
members: []
|
||||||
|
observer:
|
||||||
|
members: []
|
||||||
|
|
||||||
# Universal tool baseline. archive (record browser), browse (file
|
# Universal tool baseline. archive (record browser), browse (file
|
||||||
# tree, hosts the in-place markdown editor), and landing (project
|
# tree, hosts the in-place markdown editor), and landing (project
|
||||||
|
|
@ -123,17 +136,18 @@ available_tools: [archive, browse, landing]
|
||||||
paths:
|
paths:
|
||||||
# First segment under root is the project name; "*" matches any.
|
# First segment under root is the project name; "*" matches any.
|
||||||
"*":
|
"*":
|
||||||
# Project-scoped baseline ACL. project_team gets read across the
|
# Project-scoped baseline ACL. project_team and observer get read
|
||||||
# project; document_controller gets read + overwrite-existing
|
# across the project; document_controller gets read + overwrite-
|
||||||
# (so people can ask them to fix a stuck file). Neither gets
|
# existing (so people can ask them to fix a stuck file). None of
|
||||||
# `c` (create) at this level — that's granted only at the
|
# the three gets `c` (create) at this level — that's granted only
|
||||||
# specific spots below (archive/, working/, staging/), so the
|
# at the specific spots below (archive/, working/, staging/), so
|
||||||
# doc controller can't make arbitrary folders. Grants here cap
|
# the doc controller can't make arbitrary folders. Grants here cap
|
||||||
# at deeper levels per deepest-match-wins, except where a deeper
|
# at deeper levels per deepest-match-wins, except where a deeper
|
||||||
# .zddc restates a fuller grant for the same principal.
|
# .zddc restates a fuller grant for the same principal.
|
||||||
acl:
|
acl:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: r
|
project_team: r
|
||||||
|
observer: r
|
||||||
document_controller: rw
|
document_controller: rw
|
||||||
paths:
|
paths:
|
||||||
# ── Top-level virtual aggregators ───────────────────────────
|
# ── 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())
|
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