From 3ac53fe894e874990fb8df820019244866da9208 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 09:39:19 -0500 Subject: [PATCH] fix(fileapi): authorize creates at the logical parent, not the nearest on-disk dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authorizeAction walked `probe` up from the target's parent to the nearest EXISTING directory before computing the ACL chain. For a create deep under a not-yet-materialised canonical path — e.g. mkdir working// when working/ and working// don't exist on disk yet — that walk skipped the virtual working/ level and landed on the project root, where the embedded grant is only `document_controller: rw` (no `c`). Result: a bona-fide document_controller got 403 missing_verb=c creating in working/ (and party registration would fail the same way on a fresh project where ssr/ doesn't exist yet). EffectivePolicy is virtual-path-aware — the paths: cascade resolves per-folder behaviour for directories that don't exist on disk — so the chain must be evaluated at filepath.Dir(absPath) directly. This applies the correct per-peer grant (working/ → document_controller rwcda, project_team cr; ssr/ → document_controller rwc) regardless of what's been physically created. Ancestor restrictions (WORM zones, inherit:false fences) still apply because they cascade through EffectivePolicy, so this is strictly more correct, never more permissive than the cascade intends. Regression test: a document_controller (role member, not admin, un-elevated) registers a party and mkdirs under working//. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/internal/handler/auth_invariants_test.go | 22 ++++++++++++++++ zddc/internal/handler/fileapi.go | 25 ++++++++++--------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 7f3651c..0650436 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -519,3 +519,25 @@ func dumpBody(rec *httptest.ResponseRecorder) string { s := rec.Body.String() return strings.TrimSpace(s) } + +// Regression: "I'm a document_controller but creating a folder in working/ +// says I need document-controller permissions." A DC (role member at the site +// root, NOT an admin, un-elevated) must be able to (1) register a party by +// creating ssr/.yaml and (2) create folders under working//, +// per the embedded per-peer grants (ssr → document_controller rwc; working → +// document_controller rwcda). Exercises role resolution from a deep peer level +// back to the root role definition. +func TestInvariant_DocumentControllerRegistersPartyAndCreatesInWorking(t *testing.T) { + cfg, _ := invariantsFixture(t) + // 1. Register a new party: create ssr/.yaml. + rec := doReq(cfg, http.MethodPut, "/Project-1/ssr/Beta.yaml", "bob@example.com", false, []byte("kind: SSR\n"), "") + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + t.Fatalf("DC register party ssr/Beta.yaml: status=%d body=%s (want 201/200)", rec.Code, rec.Body.String()) + } + zddc.InvalidateCache(cfg.Root) + // 2. Create a folder under working//. + rec2 := doReq(cfg, http.MethodPost, "/Project-1/working/Beta/draft/", "bob@example.com", false, nil, "mkdir") + if rec2.Code != http.StatusCreated && rec2.Code != http.StatusOK { + t.Fatalf("DC mkdir working/Beta/draft: status=%d body=%s (want 201/200)", rec2.Code, rec2.Body.String()) + } +} diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 49bd173..3e47754 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -120,21 +120,22 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str // caller tags .zddc writes that way). The handler does NOT make // admin/elevation decisions of its own — one bypass site, one helper. func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool { - probe := filepath.Dir(absPath) - for { - info, err := os.Stat(probe) - if err == nil && info.IsDir() { - break - } - if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) { - probe = cfg.Root - break - } - probe = filepath.Dir(probe) + // Evaluate the cascade at the target's LOGICAL parent — NOT the nearest + // on-disk ancestor. EffectivePolicy is virtual-path-aware: the embedded + // paths: cascade resolves per-folder behaviour for directories that don't + // exist on disk yet. A create deep under a not-yet-materialised canonical + // path — e.g. mkdir working// when working// has never + // been created — must see the working/ grant (document_controller rwcda, + // project_team cr). Walking up to the nearest existing dir would instead + // land on the shallower project-level grant (document_controller rw, no c) + // and wrongly deny create. + dir := filepath.Dir(absPath) + if dir != cfg.Root && !strings.HasPrefix(dir, cfg.Root+string(filepath.Separator)) { + dir = cfg.Root } p := PrincipalFromContext(r) - chain, err := zddc.EffectivePolicy(cfg.Root, probe) + chain, err := zddc.EffectivePolicy(cfg.Root, dir) if err != nil { slog.Warn("file API ACL chain error", "path", absPath, "err", err) }