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()
|
||||
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
|
||||
// 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/<party>/<name> when working/<party>/ 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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue