From ef70f5dcc1e25a562b0f87eac845ff6315984361 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 22 May 2026 08:57:24 -0500 Subject: [PATCH] fix(server): serve root-level tool shells without a root read grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-HTML dispatch branch (/archive.html, /browse.html at root) gated serving the tool shell on read permission for the root dir — which no per-project-scoped user has — so the root-level multi-project archive view (/archive.html?projects=A,B) returned 403 to anyone but a root-elevated admin. The landing page (/) and ServeDirectory already treat the root path as a public shell and filter data per-project; the app-HTML branch didn't get the same bypass. Skip the read gate when the tool's request dir is the root: the shell is a static app carrying no data, and the tool's own per-project/per-dir fetches stay ACL-gated (fs.ListDirectory filters per entry). Non-root tool paths (//archive.html) keep their read gate. Test: a non-root, un-elevated user gets 200 on /archive.html but still 403 on a project directory they can't read. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 20 ++++++++++-- zddc/cmd/zddc-server/main_test.go | 52 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 770d0c6..84389c6 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1160,9 +1160,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) if apps.AppAvailableAt(cfg.Root, requestDir, app) { chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir) - if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return + // Root-path tool shells are public, mirroring the + // landing page + ServeDirectory's root bypass: the + // shell is a static app that carries no data, and the + // tool's own per-project/per-dir fetches are + // independently ACL-gated (fs.ListDirectory filters + // per entry). Gating the shell here would block the + // root-level multi-project archive/browse views for + // any caller without a root-level read grant — which + // no normal (per-project-scoped) user has. Non-root + // tool paths (e.g. //archive.html) keep the + // read gate so a project you can't read won't serve + // its tool there. + if requestDir != cfg.Root { + if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } } appsSrv.Serve(w, r, app, chain, requestDir) return diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index c7bf475..dcd067d 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -216,6 +216,58 @@ func TestDispatchAppsResolution(t *testing.T) { } } +// TestDispatchRootAppShellPublicButDataGated locks in the root-path tool-shell +// bypass: a non-root, un-elevated user (no read grant anywhere at/under root) +// can GET /archive.html — the shell is a static app, served like the landing +// page — but the underlying data stays ACL-gated, so the same user is still +// Forbidden from reading a project directory they have no grant on. This is +// what makes the root-level multi-project archive (/archive.html?projects=A,B) +// usable by per-project-scoped users without admin elevation. +func TestDispatchRootAppShellPublicButDataGated(t *testing.T) { + root := t.TempDir() + + // Root grants only alice; eve has no read grant at root or anywhere under it. + zf := zddc.ZddcFile{ + ACL: zddc.ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}}, + } + if err := zddc.WriteFile(root, zf); err != nil { + t.Fatalf("WriteFile: %v", err) + } + mustMkdir(t, filepath.Join(root, "Project-A")) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"} + ring := handler.NewLogRing(10) + appsSrv, err := setupApps(cfg) + if err != nil { + t.Fatalf("setupApps: %v", err) + } + + // eve: no grant anywhere, NOT elevated (ElevatedKey unset → false). + eveReq := func(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + return req.WithContext(context.WithValue(req.Context(), handler.EmailKey, "eve@example.com")) + } + + // The shell at root is served regardless of a root read grant. + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec, eveReq(http.MethodGet, "/archive.html")) + if rec.Code != http.StatusOK { + t.Fatalf("GET /archive.html as non-root user: status=%d, want 200 (root tool shell is public); body=%s", + rec.Code, rec.Body.String()) + } + + // ...but data is still ACL-gated: eve cannot read a project she has no grant on. + rec2 := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec2, eveReq(http.MethodGet, "/Project-A/")) + if rec2.Code != http.StatusForbidden { + t.Errorf("GET /Project-A/ as non-root user: status=%d, want 403 (data stays ACL-gated)", rec2.Code) + } +} + // silence "imported and not used" if apps not referenced elsewhere — keep // import even when we trim test cases later. var _ = apps.DefaultUpstream