diff --git a/zddc/internal/handler/profilepage.go b/zddc/internal/handler/profilepage.go index b179a79..705dc52 100644 --- a/zddc/internal/handler/profilepage.go +++ b/zddc/internal/handler/profilepage.go @@ -199,12 +199,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`

Create new project folder

-

Creates a directory under the chosen parent. Your email is added to admins automatically so you administer the new project; you can also fill title / ACL / additional admins below.

+

Creates a top-level project folder. Your email is recorded as the project's creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.

- -

ACL — Permissions (optional)

-

Pattern (email or role) → verbs (drawn from r w c d a). Empty verbs = explicit deny.

-
- -

Additional admins (optional)

+

Admins

+

Full control of the project (you are already an admin).

+

Document controllers

+

Manage filing & records — read / write / create / delete.

+
+ +

Project team

+

Contribute documents — read / write / create.

+
+ +

Guests

+

Read-only access.

+
+ +

Advanced — ACL permissions (optional)

+

Pattern (email or role) → verbs (drawn from r w c d a). Empty verbs = explicit deny. Overrides the role grants above for the same pattern.

+
+
@@ -417,26 +427,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(` entry plus a base + // acl.permissions grant (document_controller→rwcd, project_team→rwc, + // guest→r). An explicit ACL permission for the same key wins. + 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 +}{ + {"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 }}, +} + +// dedupeStrings trims, drops empties, and removes duplicates (first-wins), +// preserving order. +func dedupeStrings(in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out } // projectCreateResponse is the success payload. @@ -108,21 +144,43 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt return } - // Always seed a starter .zddc — the creator becomes subtree admin of - // their new project. Caller can also pass title / ACL / extra - // admins on top. - admins := req.Admins - if len(admins) == 0 && p.Email != "" { - admins = []string{p.Email} - } + // Always seed a starter .zddc. The creator administers their new project + // and is RECORDED as its creator (created_by, audit). Caller can also + // pass title / ACL / role groups / extra admins on top. var zf zddc.ZddcFile zf.Title = req.Title + zf.CreatedBy = p.Email if req.ACL != nil { zf.ACL = *req.ACL } - zf.Admins = admins - wantsZddc := len(zf.Admins) > 0 || zf.Title != "" || - (req.ACL != nil && len(req.ACL.Permissions) > 0) + // 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 { + members := dedupeStrings(g.pick(req)) + if len(members) == 0 { + continue + } + 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 + } + } + + // We always record the creator, so a .zddc is essentially always + // written; the guard only skips the rare anonymous-creator case with + // no other content. + wantsZddc := zf.CreatedBy != "" || len(zf.Admins) > 0 || zf.Title != "" || + len(zf.Roles) > 0 || len(zf.ACL.Permissions) > 0 if wantsZddc { if errs := zddc.ValidateFile(zf); len(errs) > 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/zddc/internal/handler/profileprojects_test.go b/zddc/internal/handler/profileprojects_test.go index 437b73e..9d62564 100644 --- a/zddc/internal/handler/profileprojects_test.go +++ b/zddc/internal/handler/profileprojects_test.go @@ -119,3 +119,65 @@ func TestProjectCreate_DuplicateNameRejected(t *testing.T) { t.Errorf("status=%d, want 409", rec.Code) } } + +// The creator is recorded in created_by (+ made admin), and the role-group +// member lists become roles{} with conventional base ACL permission grants. +func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) { + cfg, root := projectCreateFixture(t) + body, _ := json.Marshal(map[string]any{ + "parent": "/", + "name": "RoleProj", + "document_controllers": []string{"dc@example.com"}, + "project_team": []string{"t1@example.com", "t2@example.com"}, + "guests": []string{"guest@example.com"}, + }) + rec := doProjectCreate(cfg, "alice@example.com", false, body) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + zddc.InvalidateCache(root) + zf, err := zddc.ParseFile(filepath.Join(root, "RoleProj", ".zddc")) + if err != nil { + t.Fatalf("read new .zddc: %v", err) + } + if zf.CreatedBy != "alice@example.com" { + t.Errorf("CreatedBy=%q, want alice@example.com", zf.CreatedBy) + } + if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" { + t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins) + } + if r, ok := zf.Roles["document_controller"]; !ok || len(r.Members) != 1 || r.Members[0] != "dc@example.com" { + t.Errorf("document_controller role=%v", zf.Roles["document_controller"]) + } + 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"]) + } + 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) + } + } +} + +// An explicit acl.permissions entry for a role wins over the default grant. +func TestProjectCreate_ExplicitPermissionOverridesRoleGrant(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"}}, + }) + rec := doProjectCreate(cfg, "alice@example.com", false, body) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + 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"]) + } +}