fix(fileapi): authorize creates at the logical parent, not the nearest on-disk dir

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/<party>/<name> when
working/ and working/<party>/ 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/<party>/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 09:39:19 -05:00
parent 9552b297e7
commit 3ac53fe894
2 changed files with 35 additions and 12 deletions

View file

@ -519,3 +519,25 @@ func dumpBody(rec *httptest.ResponseRecorder) string {
s := rec.Body.String() s := rec.Body.String()
return strings.TrimSpace(s) 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/<party>.yaml and (2) create folders under working/<party>/,
// 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/<party>.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/<party>/.
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())
}
}

View file

@ -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 // caller tags .zddc writes that way). The handler does NOT make
// admin/elevation decisions of its own — one bypass site, one helper. // 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 { func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
probe := filepath.Dir(absPath) // Evaluate the cascade at the target's LOGICAL parent — NOT the nearest
for { // on-disk ancestor. EffectivePolicy is virtual-path-aware: the embedded
info, err := os.Stat(probe) // paths: cascade resolves per-folder behaviour for directories that don't
if err == nil && info.IsDir() { // exist on disk yet. A create deep under a not-yet-materialised canonical
break // path — e.g. mkdir working/<party>/<name> when working/<party>/ has never
} // been created — must see the working/ grant (document_controller rwcda,
if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) { // project_team cr). Walking up to the nearest existing dir would instead
probe = cfg.Root // land on the shallower project-level grant (document_controller rw, no c)
break // and wrongly deny create.
} dir := filepath.Dir(absPath)
probe = filepath.Dir(probe) if dir != cfg.Root && !strings.HasPrefix(dir, cfg.Root+string(filepath.Separator)) {
dir = cfg.Root
} }
p := PrincipalFromContext(r) p := PrincipalFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, probe) chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil { if err != nil {
slog.Warn("file API ACL chain error", "path", absPath, "err", err) slog.Warn("file API ACL chain error", "path", absPath, "err", err)
} }