feat: project creation gated by cascade ActionCreate, not hardcoded admin

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 /<project>/.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 10:25:19 -05:00
parent fd4f03afc3
commit b80b11c99f
5 changed files with 218 additions and 34 deletions

View file

@ -148,6 +148,12 @@ type AccessView struct {
IsSuperAdmin bool `json:"is_super_admin"` IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"` HasAnyAdminScope bool `json:"has_any_admin_scope"`
CanElevate bool `json:"can_elevate"` 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"` Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"` AdminSubtrees []treeEntry `json:"admin_subtrees"`
EditableParentChoices []treeEntry `json:"editable_parent_choices"` 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- // Drives the header elevation toggle's visibility — an un-
// elevated admin still needs to see the toggle they'd flip. // elevated admin still needs to see the toggle they'd flip.
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email) 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 { for _, t := range view.AdminSubtrees {
if t.CanEdit { if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t) view.EditableParentChoices = append(view.EditableParentChoices, t)

View file

@ -186,6 +186,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
</section> </section>
<div id="subtree-admin-slot"></div> <div id="subtree-admin-slot"></div>
<div id="create-project-slot"></div>
<template id="tmpl-subtree-admin"> <template id="tmpl-subtree-admin">
<section class="card"> <section class="card">
@ -193,10 +194,12 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<p class="help">Open the form-based editor for any subtree you administer.</p> <p class="help">Open the form-based editor for any subtree you administer.</p>
<div id="editable-list"></div> <div id="editable-list"></div>
</section> </section>
</template>
<template id="tmpl-create-project">
<section class="card"> <section class="card">
<h2>Create new project folder</h2> <h2>Create new project folder</h2>
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p> <p class="help">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.</p>
<div id="cp-ok" class="ok-banner" hidden>Created.</div> <div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off"> <form id="cp-form" autocomplete="off">
<label>Parent <label>Parent
@ -215,7 +218,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Deny (optional)</h3> <h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Deny (optional)</h3>
<div class="list" data-field="acl.deny"></div> <div class="list" data-field="acl.deny"></div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button> <button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3> <h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional admins (optional)</h3>
<div class="list" data-field="admins"></div> <div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button> <button type="button" class="add" data-target="admins">+ Add admin</button>
<div style="margin-top: 1rem;"> <div style="margin-top: 1rem;">
@ -250,6 +253,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
(function() { (function() {
var prefix = {{ .ProfilePathPrefix }}; var prefix = {{ .ProfilePathPrefix }};
var isSuper = {{ .IsSuperAdmin }}; var isSuper = {{ .IsSuperAdmin }};
// canCreateProject is hydrated from /.profile/access (the JSON
// refresh) — server-rendered HTML only knows IsSuperAdmin. Default
// to isSuper so the UI doesn't flicker between paint and fetch for
// super-admins; the JSON view overrides for non-admin grantees.
var canCreateProject = isSuper;
function escText(s) { function escText(s) {
var d = document.createElement("div"); var d = document.createElement("div");
@ -422,7 +430,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
var sel = document.getElementById("cp-parent"); var sel = document.getElementById("cp-parent");
if (!sel) return; if (!sel) return;
sel.innerHTML = ""; sel.innerHTML = "";
if (isSuper) { // Root is offered whenever the caller can create projects there —
// super-admin (full bypass) or cascade-granted "c" at the root.
// The server's can_create_project flag means both, since it runs
// the same decider gate the endpoint uses.
if (isSuper || canCreateProject) {
var optRoot = document.createElement("option"); var optRoot = document.createElement("option");
optRoot.value = "/"; optRoot.textContent = "/ (root)"; optRoot.value = "/"; optRoot.textContent = "/ (root)";
sel.appendChild(optRoot); sel.appendChild(optRoot);
@ -506,14 +518,27 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
} }
function instantiateAdminScaffold(view) { function instantiateAdminScaffold(view) {
if (!view.has_any_admin_scope) return; if (view.has_any_admin_scope) {
var tmpl = document.getElementById("tmpl-subtree-admin"); var tmpl = document.getElementById("tmpl-subtree-admin");
if (!tmpl) return; if (tmpl) {
var slot = document.getElementById("subtree-admin-slot"); var slot = document.getElementById("subtree-admin-slot");
slot.appendChild(tmpl.content.cloneNode(true)); slot.appendChild(tmpl.content.cloneNode(true));
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope); renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
populateParentChoices(view.admin_subtrees); }
wireCreateProjectForm(); }
// Create-project mounts independently on the can_create_project
// gate — non-admins who hold "c" at root via cascade grant get the
// form too. Parent-selector seeds from admin_subtrees when those
// exist, otherwise just root.
if (view.can_create_project) {
var cpTmpl = document.getElementById("tmpl-create-project");
if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
populateParentChoices(view.editable_parent_choices || []);
wireCreateProjectForm();
}
}
} }
fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" }) fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" })
@ -523,6 +548,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
document.getElementById("projects-loading").textContent = "Could not load access view."; document.getElementById("projects-loading").textContent = "Could not load access view.";
return; return;
} }
// Hydrate the server-computed flag so populateParentChoices and
// any visibility check after this point sees the live value.
canCreateProject = !!view.can_create_project;
renderProjects(view.projects); renderProjects(view.projects);
renderAdminSubtrees(view.admin_subtrees); renderAdminSubtrees(view.admin_subtrees);
instantiateAdminScaffold(view); instantiateAdminScaffold(view);

View file

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -38,20 +39,21 @@ type projectCreateResponse struct {
// serveProfileProjectsCreate handles POST /.profile/projects. // serveProfileProjectsCreate handles POST /.profile/projects.
// //
// Authorization is delegated to CanEditZddc on the prospective new // Authorization is the cascade's ActionCreate verb at the prospective
// directory: the caller must have authority that would let them write a // parent directory — the same `c` permission used everywhere else.
// .zddc at that location (super-admin via root admins, or a strict-ancestor // Super-admins pass via the decider's IsActiveAdmin bypass; explicitly
// admin grant). Non-authorized callers receive 404 to keep this endpoint's // granted principals (e.g., `*@example.com: c` on the root .zddc) pass
// existence hidden alongside the rest of the admin surface. // via the normal ACL path. Non-authorized callers receive 404 to keep
// this endpoint's existence hidden alongside the rest of /.profile.
//
// The new project's .zddc is seeded with the creator's email in
// `admins:` (unless the request body supplied an explicit list). That
// makes them subtree admin of their own project from birth — they can
// manage deeper .zddc files, define roles, set per-stage ACLs. The
// strict-ancestor rule still applies to /<project>/.zddc itself; that
// stays editable only by ancestor admins (root super-admins).
func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) { func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Admin gate first so non-admins (AND un-elevated admins) see 404
// regardless of method, matching the rest of /.profile/'s existence-
// leakage policy.
p := PrincipalFromContext(r) p := PrincipalFromContext(r)
if !hasAnyAdminScope(cfg.Root, p) {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST") w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
@ -81,27 +83,47 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
return return
} }
newDir := filepath.Join(parentAbs, req.Name) // Cascade-driven authorization: ActionCreate at the parent directory.
if !zddc.CanEditZddc(cfg.Root, newDir, p) { // 404 (not 403) on deny to match the rest of /.profile/'s existence-
// hiding policy.
parentChain, err := zddc.EffectivePolicy(cfg.Root, parentAbs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
parentRel, _ := filepath.Rel(cfg.Root, parentAbs)
parentURL := "/"
if parentRel != "." && parentRel != "" {
parentURL = "/" + filepath.ToSlash(parentRel) + "/"
}
allowed, _ := policy.AllowActionFromChainP(r.Context(), DeciderFromContext(r), parentChain, p, parentURL, policy.ActionCreate)
if !allowed {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
newDir := filepath.Join(parentAbs, req.Name)
if _, err := os.Stat(newDir); err == nil { if _, err := os.Stat(newDir); err == nil {
http.Error(w, "Conflict: directory already exists", http.StatusConflict) http.Error(w, "Conflict: directory already exists", http.StatusConflict)
return return
} }
// If the body supplies any .zddc fields, validate them BEFORE we mkdir // Always seed a starter .zddc — the creator becomes subtree admin of
// so a validation failure leaves no on-disk trace. // their new project. Caller can also pass title / ACL / extra
wantsZddc := req.Title != "" || (req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0)) || len(req.Admins) > 0 // admins on top.
admins := req.Admins
if len(admins) == 0 && p.Email != "" {
admins = []string{p.Email}
}
var zf zddc.ZddcFile var zf zddc.ZddcFile
zf.Title = req.Title
if req.ACL != nil {
zf.ACL = *req.ACL
}
zf.Admins = admins
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" ||
(req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0 || len(req.ACL.Permissions) > 0))
if wantsZddc { if wantsZddc {
zf.Title = req.Title
if req.ACL != nil {
zf.ACL = *req.ACL
}
zf.Admins = req.Admins
if errs := zddc.ValidateFile(zf); len(errs) > 0 { if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)

View file

@ -0,0 +1,121 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// projectCreateFixture lays out a deployment whose root .zddc grants
// `c` (create) to `*@example.com` so non-admin members can spin up
// projects, with `admin@example.com` as the super-admin escape hatch.
func projectCreateFixture(t *testing.T) (config.Config, string) {
t.Helper()
root := t.TempDir()
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - admin@example.com\n"+
"acl:\n permissions:\n \"*@example.com\": c\n")
zddc.InvalidateCache(root)
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}, root
}
func doProjectCreate(cfg config.Config, email string, elevated bool, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
serveProfileProjectsCreate(cfg, rec, req)
return rec
}
// Non-admin user holds `c` at root via cascade — succeeds, becomes
// subtree admin of the new project.
func TestProjectCreate_CascadeGrantedUserBecomesSubtreeAdmin(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Project-1", "title": "My project"})
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)
// Verify the new project .zddc names alice as admin.
zf, err := zddc.ParseFile(filepath.Join(root, "Project-1", ".zddc"))
if err != nil {
t.Fatalf("read new .zddc: %v", err)
}
if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" {
t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins)
}
if zf.Title != "My project" {
t.Errorf("Title=%q, want %q", zf.Title, "My project")
}
}
// User with no grant at root — 404 (existence-hidden).
func TestProjectCreate_UnauthenticatedUserDenied(t *testing.T) {
cfg, _ := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "ShouldFail"})
rec := doProjectCreate(cfg, "stranger@other.org", false, body)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
}
// Super-admin elevated — succeeds via decider's IsActiveAdmin bypass,
// not via explicit ACL grant. Admins default to creator email.
func TestProjectCreate_ElevatedSuperAdminWithDefaultAdmins(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "AdminProj"})
rec := doProjectCreate(cfg, "admin@example.com", true, 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, "AdminProj", ".zddc"))
if len(zf.Admins) != 1 || zf.Admins[0] != "admin@example.com" {
t.Errorf("default Admins=%v, want [admin@example.com]", zf.Admins)
}
}
// Caller passes explicit admins list — used instead of auto-defaulting.
func TestProjectCreate_ExplicitAdminsListWins(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{
"parent": "/",
"name": "TeamProj",
"admins": []string{"alice@example.com", "bob@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, _ := zddc.ParseFile(filepath.Join(root, "TeamProj", ".zddc"))
if len(zf.Admins) != 2 {
t.Errorf("Admins=%v, want 2 entries", zf.Admins)
}
}
// Re-creating an existing project name fails 409 — duplicate detection
// runs before the admin seed write.
func TestProjectCreate_DuplicateNameRejected(t *testing.T) {
cfg, root := projectCreateFixture(t)
if err := os.MkdirAll(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Existing"})
rec := doProjectCreate(cfg, "alice@example.com", false, body)
if rec.Code != http.StatusConflict {
t.Errorf("status=%d, want 409", rec.Code)
}
}

View file

@ -1559,7 +1559,7 @@ body.is-elevated {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 14:54:41 · 63fc433-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:25:14 · fd4f03a-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">