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:
parent
9552b297e7
commit
3ac53fe894
2 changed files with 35 additions and 12 deletions
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue