From b80b11c99f6f997154c43dfda0a74d4e198d1e9c Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 10:25:19 -0500 Subject: [PATCH] feat: project creation gated by cascade ActionCreate, not hardcoded admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /.profile/projects endpoint previously refused anyone without hasAnyAdminScope. Now it runs the standard decider with ActionCreate on the parent directory — super-admins still pass via the IsActiveAdmin bypass branch, and anyone the root .zddc grants `c` to (e.g. `*@example.com: c`) can self-service a project without needing an existing admin grant. Other changes in this commit: - The new project's .zddc is seeded with the creator's email in admins: when the request body doesn't supply one — they become subtree admin of their own project at birth. .zddc edits in deeper subfolders flow through their authority; strict-ancestor rule still prevents them from editing //.zddc itself. - AccessView gains can_create_project, computed by the same decider call the endpoint uses — UI and server agree on visibility with no daylight. - Profile page splits the subtree-admin template from the create- project template so the latter mounts on can_create_project, independent of has_any_admin_scope. Non-admin grantees see the form; admins keep seeing both. - Lock-in tests cover the five interesting cases: cascade-granted user succeeds and becomes subtree admin; stranger gets 404; elevated super-admin auto-defaults admins; explicit admins list wins over the default; duplicate-name 409. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/profilehandler.go | 13 ++ zddc/internal/handler/profilepage.go | 50 ++++++-- zddc/internal/handler/profileprojects.go | 66 ++++++---- zddc/internal/handler/profileprojects_test.go | 121 ++++++++++++++++++ zddc/internal/handler/tables.html | 2 +- 5 files changed, 218 insertions(+), 34 deletions(-) create mode 100644 zddc/internal/handler/profileprojects_test.go diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 6adf970..6df7dab 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -148,6 +148,12 @@ type AccessView struct { IsSuperAdmin bool `json:"is_super_admin"` HasAnyAdminScope bool `json:"has_any_admin_scope"` CanElevate bool `json:"can_elevate"` + // CanCreateProject is true when the caller is authorized to mkdir a + // new top-level project — either via the root .zddc granting `c` to + // their email/role, or via super-admin authority (elevated). Drives + // the visibility of the profile page's "+ New project" form so the + // UI doesn't dangle an affordance the server would 404. + CanCreateProject bool `json:"can_create_project"` Projects []ProjectInfo `json:"projects"` AdminSubtrees []treeEntry `json:"admin_subtrees"` EditableParentChoices []treeEntry `json:"editable_parent_choices"` @@ -173,6 +179,13 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con // Drives the header elevation toggle's visibility — an un- // elevated admin still needs to see the toggle they'd flip. view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email) + // CanCreateProject mirrors the gate in serveProfileProjectsCreate — + // same decider call, same authority, no daylight between the UI + // affordance and the endpoint. + if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate) + view.CanCreateProject = allowed + } for _, t := range view.AdminSubtrees { if t.CanEdit { view.EditableParentChoices = append(view.EditableParentChoices, t) diff --git a/zddc/internal/handler/profilepage.go b/zddc/internal/handler/profilepage.go index be27ab3..1c5df28 100644 --- a/zddc/internal/handler/profilepage.go +++ b/zddc/internal/handler/profilepage.go @@ -186,6 +186,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`
+
+