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:
parent
fbe9d11f22
commit
9552b297e7
3 changed files with 50 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue