diff --git a/zddc/internal/handler/profileprojects.go b/zddc/internal/handler/profileprojects.go index b7daede..15b842a 100644 --- a/zddc/internal/handler/profileprojects.go +++ b/zddc/internal/handler/profileprojects.go @@ -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: 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: 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 diff --git a/zddc/internal/handler/profileprojects_test.go b/zddc/internal/handler/profileprojects_test.go index 9d62564..f34e8f1 100644 --- a/zddc/internal/handler/profileprojects_test.go +++ b/zddc/internal/handler/profileprojects_test.go @@ -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") } } diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 079e932..2620d94 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -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/ default_tool: tables