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(`
+
@@ -193,10 +194,12 @@ var profileTemplate = template.Must(template.New("profile").Parse(`Open the form-based editor for any subtree you administer.
+
+
Create new project folder
-
Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter .zddc is also written; otherwise the directory is empty and inherits ACL from its ancestors.
+
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.