diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index a54c545..4485980 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -169,15 +169,19 @@ func WithElevation(ctx context.Context, elevated bool) context.Context { // activeAdminForRequest reports whether the elevated principal would // trigger the decider's admin-bypass branch on the chain at the -// request's target path. Best-effort: walks the closest existing -// ancestor (mirroring the file API's authorize logic) so a write -// targeting a not-yet-existing file still answers correctly. Returns -// false on anonymous or un-elevated requests without touching the -// filesystem. The cascade is mtime-cached upstream, so the per- -// request cost is one map lookup in the common case. -func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) bool { +// request's target path, AND which chain level conferred that +// authority. Returned level is 0-based (root=0) when authority is +// active, -1 otherwise. +// +// Best-effort: walks the closest existing ancestor (mirroring the +// file API's authorize logic) so a write targeting a not-yet- +// existing file still answers correctly. Returns -1 on anonymous +// or un-elevated requests without touching the filesystem. The +// cascade is mtime-cached upstream, so the per-request cost is one +// map lookup in the common case. +func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int { if !elevated || email == "" || email == "anonymous" { - return false + return -1 } cleanURL := strings.TrimSuffix(r.URL.Path, "/") if cleanURL == "" { @@ -188,13 +192,13 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em // Root request: chain is just the root .zddc. chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root) if err != nil { - return false + return -1 } - return zddc.IsAdminForChain(chain, email, false) + return zddc.AdminLevelInChain(chain, email, false) } abs := filepath.Join(cfg.Root, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { - return false + return -1 } probe := abs for { @@ -212,9 +216,9 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em } chain, err := zddc.EffectivePolicy(cfg.Root, probe) if err != nil { - return false + return -1 } - return zddc.IsAdminForChain(chain, email, false) + return zddc.AdminLevelInChain(chain, email, false) } // PrincipalFromContext bundles the request's authenticated email plus @@ -334,13 +338,20 @@ func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http. email = "anonymous" } elevated := ElevatedFromContext(r) - activeAdmin := activeAdminForRequest(cfg, r, elevated, email) + // adminLevel: 0-based chain index of the admins: entry that + // conferred authority on this request, or -1 if no admin + // authority applies. Lets forensics tell "root admin acted" + // (level 0) apart from "subtree admin acted" (level N) apart + // from "not admin" (-1). The active_admin bool is derived + // for back-compat with existing log consumers. + adminLevel := activeAdminForRequest(cfg, r, elevated, email) args := []any{ "ts", start.Format(time.RFC3339), "email", email, "elevated", elevated, - "active_admin", activeAdmin, + "active_admin", adminLevel >= 0, + "chain_admin_level", adminLevel, "method", r.Method, "path", requestedPath, "status", wrapped.status, diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 2772786..0392086 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -699,6 +699,8 @@ func TestServeProfileProjectsCreate(t *testing.T) { } // Happy path: super-admin creates /alpha with no .zddc body. + // Post-refactor: the .zddc IS auto-written with the creator in + // admins: so they own the new project from birth. rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`) if rec.Code != http.StatusCreated { t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String()) @@ -706,8 +708,12 @@ func TestServeProfileProjectsCreate(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil { t.Errorf("alpha dir not created on disk: %v", err) } - if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil { - t.Errorf(".zddc should NOT be auto-written when no fields supplied") + if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil { + t.Errorf(".zddc should be auto-written with creator as admin: %v", err) + } else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil { + if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" { + t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins) + } } // Body with a title also writes a .zddc. diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 2dad3ad..44ad816 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1559,7 +1559,7 @@ body.is-elevated {