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:
parent
fd4f03afc3
commit
b80b11c99f
5 changed files with 218 additions and 34 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
</section>
|
||||
|
||||
<div id="subtree-admin-slot"></div>
|
||||
<div id="create-project-slot"></div>
|
||||
|
||||
<template id="tmpl-subtree-admin">
|
||||
<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>
|
||||
<div id="editable-list"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="tmpl-create-project">
|
||||
<section class="card">
|
||||
<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>
|
||||
<form id="cp-form" autocomplete="off">
|
||||
<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>
|
||||
<div class="list" data-field="acl.deny"></div>
|
||||
<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>
|
||||
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||||
<div style="margin-top: 1rem;">
|
||||
|
|
@ -250,6 +253,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
(function() {
|
||||
var prefix = {{ .ProfilePathPrefix }};
|
||||
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) {
|
||||
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");
|
||||
if (!sel) return;
|
||||
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");
|
||||
optRoot.value = "/"; optRoot.textContent = "/ (root)";
|
||||
sel.appendChild(optRoot);
|
||||
|
|
@ -506,14 +518,27 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
}
|
||||
|
||||
function instantiateAdminScaffold(view) {
|
||||
if (!view.has_any_admin_scope) return;
|
||||
var tmpl = document.getElementById("tmpl-subtree-admin");
|
||||
if (!tmpl) return;
|
||||
var slot = document.getElementById("subtree-admin-slot");
|
||||
slot.appendChild(tmpl.content.cloneNode(true));
|
||||
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
|
||||
populateParentChoices(view.admin_subtrees);
|
||||
wireCreateProjectForm();
|
||||
if (view.has_any_admin_scope) {
|
||||
var tmpl = document.getElementById("tmpl-subtree-admin");
|
||||
if (tmpl) {
|
||||
var slot = document.getElementById("subtree-admin-slot");
|
||||
slot.appendChild(tmpl.content.cloneNode(true));
|
||||
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
|
||||
}
|
||||
}
|
||||
// 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" })
|
||||
|
|
@ -523,6 +548,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
document.getElementById("projects-loading").textContent = "Could not load access view.";
|
||||
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);
|
||||
renderAdminSubtrees(view.admin_subtrees);
|
||||
instantiateAdminScaffold(view);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
|
|
@ -38,20 +39,21 @@ type projectCreateResponse struct {
|
|||
|
||||
// serveProfileProjectsCreate handles POST /.profile/projects.
|
||||
//
|
||||
// Authorization is delegated to CanEditZddc on the prospective new
|
||||
// directory: the caller must have authority that would let them write a
|
||||
// .zddc at that location (super-admin via root admins, or a strict-ancestor
|
||||
// admin grant). Non-authorized callers receive 404 to keep this endpoint's
|
||||
// existence hidden alongside the rest of the admin surface.
|
||||
// Authorization is the cascade's ActionCreate verb at the prospective
|
||||
// parent directory — the same `c` permission used everywhere else.
|
||||
// Super-admins pass via the decider's IsActiveAdmin bypass; explicitly
|
||||
// granted principals (e.g., `*@example.com: c` on the root .zddc) pass
|
||||
// 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) {
|
||||
// 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)
|
||||
if !hasAnyAdminScope(cfg.Root, p) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
|
|
@ -81,27 +83,47 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
|
|||
return
|
||||
}
|
||||
|
||||
newDir := filepath.Join(parentAbs, req.Name)
|
||||
if !zddc.CanEditZddc(cfg.Root, newDir, p) {
|
||||
// Cascade-driven authorization: ActionCreate at the parent directory.
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
newDir := filepath.Join(parentAbs, req.Name)
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
http.Error(w, "Conflict: directory already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// If the body supplies any .zddc fields, validate them BEFORE we mkdir
|
||||
// so a validation failure leaves no on-disk trace.
|
||||
wantsZddc := req.Title != "" || (req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0)) || len(req.Admins) > 0
|
||||
// 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}
|
||||
}
|
||||
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 {
|
||||
zf.Title = req.Title
|
||||
if req.ACL != nil {
|
||||
zf.ACL = *req.ACL
|
||||
}
|
||||
zf.Admins = req.Admins
|
||||
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
|
|
|||
121
zddc/internal/handler/profileprojects_test.go
Normal file
121
zddc/internal/handler/profileprojects_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1559,7 +1559,7 @@ body.is-elevated {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue