fix(project-create): seed role membership only; grant team rwc on mdl/rsk

My earlier create-project flow wrote per-role verb grants (project_team: rwc,
…) at the PROJECT ROOT, which cascaded create/write across the whole project —
wrong. The project root is structurally locked to canonical peers
(rejectProjectRootMkdir), and the embedded defaults already grant each role its
per-FOLDER permissions ("None gets `c` here — create is granted only at the
specific peers below").

Project-create now writes role MEMBERSHIP only (document_controller /
project_team / observer) plus admins + created_by. Membership unions across the
cascade, so naming members at the project root makes the embedded per-peer
grants apply to them. No acl.permissions is seeded (the advanced field is still
an escape hatch). The dialog's "Guests" maps to the defaults' read-only
`observer` role (was a non-existent `guest` role that hooked no grants).

Per decision, MDL & RSK are now collaboratively editable: defaults grant
project_team rwc (create + edit, no delete) at mdl/ and rsk/ alongside
document_controller rwcd — the history: audit on both covers every change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 09:29:34 -05:00
parent fbe9d11f22
commit 9552b297e7
3 changed files with 50 additions and 37 deletions

View file

@ -31,24 +31,26 @@ type projectCreateRequest struct {
ACL *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
// Role groups: member email lists for the conventional project roles.
// Each non-empty list becomes a roles:<name> entry plus a base
// acl.permissions grant (document_controller→rwcd, project_team→rwc,
// guest→r). An explicit ACL permission for the same key wins.
// Each non-empty list becomes a roles:<name> MEMBERSHIP entry. Verbs are
// NOT set here — the embedded defaults grant each role its per-folder
// permissions (read across the project; create in the workspaces; WORM
// archive; rwc on mdl/rsk for the team). The "Guests" UI field maps to
// the read-only `observer` role used by those defaults.
DocumentControllers []string `json:"document_controllers,omitempty"`
ProjectTeam []string `json:"project_team,omitempty"`
Guests []string `json:"guests,omitempty"`
}
// projectRoleGrants maps each role group to its name + base verb grant, in a
// stable order. The single source of truth for the create flow's role seeding.
var projectRoleGrants = []struct {
name string
verbs string
pick func(projectCreateRequest) []string
// projectRoleGroups maps each create-dialog member list to the canonical role
// it populates. Membership only — verbs live in the embedded defaults, which
// reference these exact role names. Stable order for deterministic output.
var projectRoleGroups = []struct {
role string
pick func(projectCreateRequest) []string
}{
{"document_controller", "rwcd", func(r projectCreateRequest) []string { return r.DocumentControllers }},
{"project_team", "rwc", func(r projectCreateRequest) []string { return r.ProjectTeam }},
{"guest", "r", func(r projectCreateRequest) []string { return r.Guests }},
{"document_controller", func(r projectCreateRequest) []string { return r.DocumentControllers }},
{"project_team", func(r projectCreateRequest) []string { return r.ProjectTeam }},
{"observer", func(r projectCreateRequest) []string { return r.Guests }},
}
// dedupeStrings trims, drops empties, and removes duplicates (first-wins),
@ -156,10 +158,12 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
// Creator is always an admin (deduped, first), then any extra admins.
zf.Admins = dedupeStrings(append([]string{p.Email}, req.Admins...))
// Role groups → named roles + conventional base verb grants. Each
// non-empty member list defines a role; an explicit acl.permissions
// entry for the same key (passed via the advanced ACL field) wins.
for _, g := range projectRoleGrants {
// Role groups → role MEMBERSHIP at the project root. No verbs are written
// here: the embedded defaults already grant document_controller /
// project_team / observer their per-folder permissions, and membership
// unions across the cascade — so naming members here is enough. (An
// operator can still add explicit acl.permissions via the advanced field.)
for _, g := range projectRoleGroups {
members := dedupeStrings(g.pick(req))
if len(members) == 0 {
continue
@ -167,13 +171,7 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
if zf.Roles == nil {
zf.Roles = map[string]zddc.Role{}
}
zf.Roles[g.name] = zddc.Role{Members: members}
if zf.ACL.Permissions == nil {
zf.ACL.Permissions = map[string]string{}
}
if _, ok := zf.ACL.Permissions[g.name]; !ok {
zf.ACL.Permissions[g.name] = g.verbs
}
zf.Roles[g.role] = zddc.Role{Members: members}
}
// We always record the creator, so a .zddc is essentially always

View file

@ -121,7 +121,8 @@ func TestProjectCreate_DuplicateNameRejected(t *testing.T) {
}
// The creator is recorded in created_by (+ made admin), and the role-group
// member lists become roles{} with conventional base ACL permission grants.
// member lists become roles{} MEMBERSHIP — with NO root verb grants (verbs
// come from the embedded per-folder defaults). "guests" maps to `observer`.
func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{
@ -152,24 +153,28 @@ func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) {
if r, ok := zf.Roles["project_team"]; !ok || len(r.Members) != 2 {
t.Errorf("project_team role=%v", zf.Roles["project_team"])
}
if r, ok := zf.Roles["guest"]; !ok || len(r.Members) != 1 {
t.Errorf("guest role=%v", zf.Roles["guest"])
// "guests" populates the read-only observer role used by the defaults.
if r, ok := zf.Roles["observer"]; !ok || len(r.Members) != 1 || r.Members[0] != "guest@example.com" {
t.Errorf("observer role=%v", zf.Roles["observer"])
}
for role, verbs := range map[string]string{"document_controller": "rwcd", "project_team": "rwc", "guest": "r"} {
if zf.ACL.Permissions[role] != verbs {
t.Errorf("permission %s=%q, want %q", role, zf.ACL.Permissions[role], verbs)
}
if _, ok := zf.Roles["guest"]; ok {
t.Errorf("should not create a 'guest' role; it maps to observer")
}
// No verbs seeded at the project root — verbs come from the cascade.
if len(zf.ACL.Permissions) != 0 {
t.Errorf("project root should carry no acl.permissions, got %v", zf.ACL.Permissions)
}
}
// An explicit acl.permissions entry for a role wins over the default grant.
func TestProjectCreate_ExplicitPermissionOverridesRoleGrant(t *testing.T) {
// The advanced acl.permissions field still passes through verbatim (the
// escape hatch for operators who want explicit project-root grants).
func TestProjectCreate_AdvancedACLPassesThrough(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{
"parent": "/",
"name": "OverrideProj",
"project_team": []string{"t@example.com"},
"acl": map[string]any{"permissions": map[string]string{"project_team": "r"}},
"acl": map[string]any{"permissions": map[string]string{"*@vendor.com": "r"}},
})
rec := doProjectCreate(cfg, "alice@example.com", false, body)
if rec.Code != http.StatusCreated {
@ -177,7 +182,10 @@ func TestProjectCreate_ExplicitPermissionOverridesRoleGrant(t *testing.T) {
}
zddc.InvalidateCache(root)
zf, _ := zddc.ParseFile(filepath.Join(root, "OverrideProj", ".zddc"))
if zf.ACL.Permissions["project_team"] != "r" {
t.Errorf("explicit permission should win: got %q want r", zf.ACL.Permissions["project_team"])
if zf.ACL.Permissions["*@vendor.com"] != "r" {
t.Errorf("advanced ACL should pass through: got %q want r", zf.ACL.Permissions["*@vendor.com"])
}
if _, ok := zf.Roles["project_team"]; !ok {
t.Errorf("project_team role missing alongside explicit ACL")
}
}

View file

@ -255,11 +255,15 @@ paths:
available_tools: [tables]
party_source: ssr
history: true
# The DC maintains the deliverables register (create/edit/delete
# rows). project_team reads it (inherited from the project level).
# The deliverables register is collaboratively editable: the DC
# manages it (rwcd) and project_team can create + edit rows (rwc,
# no delete) — every change is captured by the history: audit above,
# so broad write is safe. This project_team: rwc overrides the
# project-level project_team: r (deepest matching level wins).
acl:
permissions:
document_controller: rwcd
project_team: rwc
# field_codes: constrain tracking-number components here (or
# higher in the cascade). Three kinds — enum / pattern / free;
# map-merge across levels. originator is folder-bound (below),
@ -290,9 +294,12 @@ paths:
available_tools: [tables]
party_source: ssr
history: true
# Same as mdl/: DC manages (rwcd), project_team creates + edits rows
# (rwc, no delete); the history: audit covers every change.
acl:
permissions:
document_controller: rwcd
project_team: rwc
paths:
"*": # rsk/<party>
default_tool: tables