fix(roles): restate document_controller at project_team slot grants

DCs are typically internal employees and ARE in project_team (when
project_team is the realistic *@example.com wildcard). The cascade's
"deepest level that has any matching principal wins" semantic means
a project_team:cr grant at the slot level would shadow the DC's
party-level rwcda — leaving DCs limited to project_team's grant.

Fix: at every slot with a project_team-specific grant, restate
document_controller's role grant. The within-level union of all
matched principals then gives the DC rwcda ∪ cr = rwcda. No cascade
semantics change; just verbose defaults.

  working/   project_team: cr, document_controller: rwcda  (new DC line)
  staging/   project_team: cr, document_controller: rwcda  (upgraded from rwcd —
                                                            adds `a` for
                                                            Plan Review's
                                                            staging/<tracking>/.zddc)
  reviewing/ project_team: cr, document_controller: rwcda  (new DC line)

Test fixture flipped from disjoint-role members to the realistic
project_team: ["*@example.com"]; verifies DC's rwcda survives the
wildcard via within-level union at each slot.

Docs updated:
  - AGENTS.md "Standard roles": describes the role-restate pattern
    + flags the internal-observer-via-wildcard caveat (operators
    needing internal observers should avoid the *@ wildcard for
    project_team).
  - ARCHITECTURE.md "Standard roles": same model description; drops
    the now-incorrect "subtree-admin of every archive/<party>/"
    line, replaces with the auto_own_roles role grant.
  - planreview_test.go fixture comment: reflects that the test
    uses root-admin to bypass ACLs, with non-root-admin DC path
    covered by standardroles tests' auto-own .zddc simulation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 11:03:42 -05:00
parent ba98b87b2a
commit 736f422f82
5 changed files with 67 additions and 21 deletions

View file

@ -494,14 +494,22 @@ roles:
- auditor@regulator.gov
```
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`.
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`, `rwcd` at `incoming/` and `staging/` for the QC + transfer workflows). When DC creates an `archive/<party>/` folder the auto-own `.zddc` written there grants both their email AND the `document_controller` role `rwcda` (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. 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`.
The in-flight lifecycle slots form a one-way ratchet:
`working/``staging/``issued/` (WORM)
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside their auto-own-fenced `<email>/` home. At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/``received/` (WORM).
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`).
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list) or reach inside fenced working homes.
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Owns their `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.
**Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.)
**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.
@ -509,8 +517,18 @@ Pick a role per persona:
- 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.
- `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`.
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
**Two `inherit` scopes, one word.** `ZddcFile.Inherit` (top-level) drops the embedded baseline AND fences ancestor on-disk `.zddc` files from this point of the cascade. `ACLRules.Inherit` (nested under `acl:`) is narrower — it only fences ACL evaluation; embedded roles, paths-tree contributions, WORM lists, and other non-ACL keys still cascade through. Concretely:
- To opt out of embedded defaults at deployment, set `inherit: false` at the root `<ZDDC_ROOT>/.zddc` (top-level).
- To make a per-user home private (block ancestor read grants) but keep cascade-derived behaviour like default_tool, set `acl: { inherit: false }`. The auto-own-fenced mechanism uses this form.
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
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

View file

@ -715,7 +715,7 @@ The schema keys that drive built-in behavior:
**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.
- `document_controller` — read/write across a project, `rwc` at `archive/`. When a DC mkdir's `archive/<party>/`, the auto-own `.zddc` grants both their email AND the `document_controller` role `rwcda` at that party (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Explicit `rwcd` at `incoming/` and `staging/` shadows the inherited `rwcda` to make the transfer-workflow's `d` requirement obvious. WORM-create principal in `received/issued` via the `worm:` list. NOT a subtree-admin anywhere — admin elevation is reserved for the root `admins:` list (the human escape hatch). 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.

View file

@ -23,11 +23,16 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo
t.Helper()
root := t.TempDir()
// Root .zddc grants alice subtree-admin everywhere AND sets the
// document_controller role so the cascade's reviewing/+staging/
// admin grants resolve to her. The role membership also confers
// `c` authority on received/ via the WORM list in the defaults,
// which Plan Review's pre-flight requires.
// Root .zddc grants alice root-admin AND adds her to the
// document_controller role. The root-admin status + elevated
// principal (set on the request below) is what carries her past
// Plan Review's ActionAdmin checks — DCs are no longer subtree-
// admin by default; their party-level `a` verb comes from the
// auto-own .zddc that ensure.go writes when they mkdir
// archive/<party>/ (carrying auto_own_roles: [document_controller]
// from the defaults). This fixture uses root-admin to keep the
// test self-contained without scaffolding a party folder; the
// non-root-admin DC path is covered by the standardroles tests.
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - alice@example.com\n"+
"roles:\n document_controller:\n members: [alice@example.com]\n")

View file

@ -397,9 +397,19 @@ paths:
# working/ root stays readable to all team members
# (cascade is per-level deepest-match — a single `c`
# would shadow the project-level `r`).
#
# `document_controller: rwcda` is restated here so a
# DC whose email is ALSO matched by project_team
# (typical when project_team is `*@example.com`) gets
# the higher grant via within-level union. Without
# the restatement, the cascade's deepest-level-wins
# would pick project_team's cr and shadow the DC's
# rwcda inherited from the party's auto-own .zddc.
# Same pattern applied at staging/ and reviewing/.
acl:
permissions:
project_team: cr
document_controller: rwcda
# working/ auto-owns the first creator + the per-user
# homes below.
auto_own: true
@ -434,13 +444,16 @@ paths:
# mkdir flow; project_team can keep to file drops to
# honour the "can't alter after" intent.
#
# DC gets rwcd explicitly — the staging-to-issued
# transfer needs `d` (cut, not copy) to move files
# out. Mirrors the incoming/ pattern at line 286-288.
# DC gets rwcda explicitly — `d` for the cut to issued/,
# `a` so Plan Review can write the staging/<tracking>/.zddc
# the composite endpoint scaffolds. Restated here (not
# inherited from the party-level role grant) so the
# within-level union dominates project_team's cr for
# any DC matched by the team wildcard.
acl:
permissions:
project_team: cr
document_controller: rwcd
document_controller: rwcda
auto_own: true
drop_target: true
reviewing:
@ -462,8 +475,14 @@ paths:
# here, unlike working/) gives the creator rwcda
# inside; siblings see the iteration via the project-
# level project_team:r cascade.
#
# document_controller: rwcda restated for the same
# reason as working/ + staging/ — keeps a DC matched
# by the project_team wildcard at full authority via
# within-level union.
acl:
permissions:
project_team: cr
document_controller: rwcda
auto_own: true
drop_target: true

View file

@ -25,16 +25,18 @@ import (
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
resetCache()
root := t.TempDir()
// Note: project_team's wildcard would normally also match dc@,
// which would shadow the role's rwcda at working/ (where the slot
// explicitly grants project_team:cr). Real deployments keep
// document_controller and project_team disjoint; the test fixture
// mirrors that.
// DCs are typically internal employees and ARE in project_team
// (which is commonly defined as the *@example.com wildcard). The
// embedded defaults restate document_controller:rwcda at every
// slot that grants project_team a narrower verb set; the
// cascade's within-level union then gives the DC the higher
// grant. This fixture mirrors the realistic deployment shape so
// the union behavior is actually exercised.
writeZddc(t, root, `roles:
document_controller:
members: ["dc@example.com"]
project_team:
members: ["alice@example.com"]
members: ["*@example.com"]
`)
// Simulate the auto-own .zddc the file API writes when DC mkdir's
// archive/Acme/. Carries the creator email + the document_controller
@ -82,10 +84,12 @@ created_by: dc@example.com
// party-level role grant where no slot-local grant overrides.
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda")
// incoming/ and staging/ have explicit document_controller: rwcd
// incoming/ has explicit document_controller: rwcd
// — leaf-wins shadows the rwcda inherited from <party>/.
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
mustVerbs(filepath.Join(partyDir, "staging"), "rwcd")
// staging/ has explicit document_controller: rwcda (rwcd for
// transfer + `a` for Plan Review's staging/<tracking>/.zddc).
mustVerbs(filepath.Join(partyDir, "staging"), "rwcda")
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
mustVerbs(filepath.Join(partyDir, "received"), "rc")
mustVerbs(filepath.Join(partyDir, "issued"), "rc")