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:
parent
ba98b87b2a
commit
736f422f82
5 changed files with 67 additions and 21 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -494,14 +494,22 @@ roles:
|
||||||
- auditor@regulator.gov
|
- 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:
|
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/`.
|
- `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. Read across the project; full control of their own `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
|
- `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.
|
- `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`):
|
**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.
|
- `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 `@`).
|
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
|
||||||
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
||||||
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
|
- `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.
|
- `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.).
|
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
|
### Build
|
||||||
|
|
|
||||||
|
|
@ -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):
|
**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.
|
- `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.
|
- `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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,16 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
// Root .zddc grants alice subtree-admin everywhere AND sets the
|
// Root .zddc grants alice root-admin AND adds her to the
|
||||||
// document_controller role so the cascade's reviewing/+staging/
|
// document_controller role. The root-admin status + elevated
|
||||||
// admin grants resolve to her. The role membership also confers
|
// principal (set on the request below) is what carries her past
|
||||||
// `c` authority on received/ via the WORM list in the defaults,
|
// Plan Review's ActionAdmin checks — DCs are no longer subtree-
|
||||||
// which Plan Review's pre-flight requires.
|
// 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"),
|
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||||
"admins:\n - alice@example.com\n"+
|
"admins:\n - alice@example.com\n"+
|
||||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||||
|
|
|
||||||
|
|
@ -397,9 +397,19 @@ paths:
|
||||||
# working/ root stays readable to all team members
|
# working/ root stays readable to all team members
|
||||||
# (cascade is per-level deepest-match — a single `c`
|
# (cascade is per-level deepest-match — a single `c`
|
||||||
# would shadow the project-level `r`).
|
# 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:
|
acl:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: cr
|
project_team: cr
|
||||||
|
document_controller: rwcda
|
||||||
# working/ auto-owns the first creator + the per-user
|
# working/ auto-owns the first creator + the per-user
|
||||||
# homes below.
|
# homes below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
|
@ -434,13 +444,16 @@ paths:
|
||||||
# mkdir flow; project_team can keep to file drops to
|
# mkdir flow; project_team can keep to file drops to
|
||||||
# honour the "can't alter after" intent.
|
# honour the "can't alter after" intent.
|
||||||
#
|
#
|
||||||
# DC gets rwcd explicitly — the staging-to-issued
|
# DC gets rwcda explicitly — `d` for the cut to issued/,
|
||||||
# transfer needs `d` (cut, not copy) to move files
|
# `a` so Plan Review can write the staging/<tracking>/.zddc
|
||||||
# out. Mirrors the incoming/ pattern at line 286-288.
|
# 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:
|
acl:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: cr
|
project_team: cr
|
||||||
document_controller: rwcd
|
document_controller: rwcda
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
reviewing:
|
reviewing:
|
||||||
|
|
@ -462,8 +475,14 @@ paths:
|
||||||
# here, unlike working/) gives the creator rwcda
|
# here, unlike working/) gives the creator rwcda
|
||||||
# inside; siblings see the iteration via the project-
|
# inside; siblings see the iteration via the project-
|
||||||
# level project_team:r cascade.
|
# 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:
|
acl:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: cr
|
project_team: cr
|
||||||
|
document_controller: rwcda
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,18 @@ import (
|
||||||
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// Note: project_team's wildcard would normally also match dc@,
|
// DCs are typically internal employees and ARE in project_team
|
||||||
// which would shadow the role's rwcda at working/ (where the slot
|
// (which is commonly defined as the *@example.com wildcard). The
|
||||||
// explicitly grants project_team:cr). Real deployments keep
|
// embedded defaults restate document_controller:rwcda at every
|
||||||
// document_controller and project_team disjoint; the test fixture
|
// slot that grants project_team a narrower verb set; the
|
||||||
// mirrors that.
|
// 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:
|
writeZddc(t, root, `roles:
|
||||||
document_controller:
|
document_controller:
|
||||||
members: ["dc@example.com"]
|
members: ["dc@example.com"]
|
||||||
project_team:
|
project_team:
|
||||||
members: ["alice@example.com"]
|
members: ["*@example.com"]
|
||||||
`)
|
`)
|
||||||
// Simulate the auto-own .zddc the file API writes when DC mkdir's
|
// Simulate the auto-own .zddc the file API writes when DC mkdir's
|
||||||
// archive/Acme/. Carries the creator email + the document_controller
|
// 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.
|
// party-level role grant where no slot-local grant overrides.
|
||||||
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
|
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
|
||||||
mustVerbs(filepath.Join(partyDir, "reviewing"), "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>/.
|
// — leaf-wins shadows the rwcda inherited from <party>/.
|
||||||
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
|
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.
|
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
|
||||||
mustVerbs(filepath.Join(partyDir, "received"), "rc")
|
mustVerbs(filepath.Join(partyDir, "received"), "rc")
|
||||||
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
|
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue