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
|
||||
```
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue