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