diff --git a/browse/js/util.js b/browse/js/util.js index 526004b..38060ab 100644 --- a/browse/js/util.js +++ b/browse/js/util.js @@ -91,14 +91,16 @@ } // isEditableZipMember reports whether node is a member of the .zddc.zip - // config bundle AND the session is elevated — the one case where the server - // accepts a write into a zip (ServeZipWrite, admin-gated). Every other zip - // member (content archives, or the bundle when not elevated) stays - // read-only. The server is the real gate; this just drives editor UX. + // config bundle — the one case where the server accepts a write into a zip + // (ServeZipWrite). The server gates BOTH browsing and writing the bundle on + // standing config-edit authority (a subtree admin / `a`-verb holder, no + // elevation), so if this member is even visible the session can edit it — + // no elevation check needed here. Every other zip member (content archives, + // WORM records) stays read-only. The server is the real gate; this drives + // editor UX. function isEditableZipMember(node) { if (!node || !node.url || window.app.state.source !== 'server') return false; - if (!/\.zddc\.zip\//i.test(node.url)) return false; - return !!(window.zddc && window.zddc.elevation && window.zddc.elevation.isElevated()); + return /\.zddc\.zip\//i.test(node.url); } // Thrown by saveFile when the server rejects a write with 412 diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 0e3d1ef..1c9c107 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -714,16 +714,20 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) { return "", "", false } -// activeAdminForBundle reports whether the request principal is an active -// (elevated) admin over the directory that holds the .zddc.zip config bundle -// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is -// existence-hidden config for everyone else, but an elevated admin over its -// directory may browse its members and download it. Works for every bundle URL -// shape (bare, trailing-slash listing, and /) since it keys off -// the path segment that precedes the bundle name. -func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool { +// configEditorForBundle reports whether the request principal holds STANDING +// config-edit authority over the directory that holds the .zddc.zip config +// bundle referenced by urlPath — a subtree admin (admins: cascade) or `a`-verb +// holder, WITHOUT elevation. Both browsing the bundle's members and writing +// them are gated by this: config you administer is visible+editable without a +// toggle. The bundle is NOT wide-readable, because it packs many subtrees' +// policy into one file — exposing it to every reader would leak a tightened +// subtree's rules; per-level transparency is served by ServeZddcFile instead. +// Elevation isn't required here; it only adds the WORM/destructive overrides +// elsewhere. Works for every bundle URL shape (bare, trailing-slash listing, +// and /) since it keys off the segment before the bundle name. +func configEditorForBundle(cfg config.Config, r *http.Request, urlPath string) bool { p := handler.PrincipalFromContext(r) - if !p.Elevated || p.Email == "" { + if p.Email == "" { return false } parent := make([]string, 0) @@ -735,7 +739,7 @@ func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bo } dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/"))) chain, _ := zddc.EffectivePolicy(cfg.Root, dir) - return zddc.IsAdminForChain(chain, p.Email) + return zddc.IsConfigEditor(chain, p.Email) } // dispatch routes a request to the appropriate handler. @@ -851,16 +855,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } // The site-root config bundle /.zddc.zip is config, not - // ordinary content: existence-hidden over HTTP for everyone EXCEPT an - // active (elevated) admin over its directory, who may browse it in the - // file tree. For an admin every bundle URL falls through to normal - // handling — GET / lists its members (the zip-as-directory - // intercept below), GET /member extracts one, and a bare - // GET downloads it. Everyone else gets 404 for every form, - // which also keeps individual members from being fetched by name. The - // server reads members from the filesystem internally (apps.Bundle) to - // resolve tool HTML — that path never goes through dispatch, so this - // gate doesn't affect resolution. + // ordinary content: existence-hidden over HTTP for everyone EXCEPT a + // standing config-editor over its directory (a subtree admin or `a`-verb + // holder — NO elevation required), who may browse it in the file tree. + // It's NOT wide-readable because one file packs many subtrees' policy; + // per-level transparency is served by ServeZddcFile. For a config-editor + // every bundle URL falls through to normal handling — GET / lists + // its members (the zip-as-directory intercept below), GET /member + // extracts one, and a bare GET downloads it. Everyone else gets + // 404 for every form, which also keeps individual members from being + // fetched by name. The server reads members from the filesystem internally + // (apps.Bundle) to resolve tool HTML — that path never goes through + // dispatch, so this gate doesn't affect resolution. bundlePath := false for _, seg := range segments { if strings.EqualFold(seg, apps.BundleName) { @@ -868,7 +874,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps break } } - if bundlePath && !activeAdminForBundle(cfg, r, urlPath) { + if bundlePath && !configEditorForBundle(cfg, r, urlPath) { http.NotFound(w, r) return } @@ -982,11 +988,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps if zipAbs, member, ok := splitZipPath(cfg.Root, urlPath); ok { if handler.IsWriteMethod(r.Method) { // In-place editing is allowed ONLY inside the .zddc.zip config - // bundle and ONLY for an active admin (the bundle gate above - // already 404s the bundle to everyone else). Content zips — - // transmittal packages, WORM records — stay read-only. + // bundle and ONLY for a standing config-editor over its dir + // (the bundle gate above already 404s the bundle to everyone + // else, so visibility ⇒ edit authority — no elevation). Content + // zips — transmittal packages, WORM records — stay read-only. if strings.EqualFold(filepath.Base(zipAbs), apps.BundleName) && - activeAdminForBundle(cfg, r, urlPath) { + configEditorForBundle(cfg, r, urlPath) { handler.ServeZipWrite(cfg, w, r, zipAbs, member) return } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 06a280c..8109bdd 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -1181,11 +1181,16 @@ func TestDispatchBundleAdminView(t *testing.T) { t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code) } - // Same admin un-elevated → 404 (sudo model: powers are per-request). - if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound { - t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code) + // Same admin un-elevated → STILL visible: config-edit is standing, so a + // subtree admin browses the bundle without elevating (elevation only adds + // the WORM/destructive overrides, not config visibility/edit). + if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusOK { + t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 200 (standing config-edit)", rec.Code) } - // Non-admin reader → 404 for listing AND by-name member (no leak). + // Non-admin reader (bob has `r` but no admin/`a`) → 404 for listing AND + // by-name member: the bundle is scoped to config-EDITORS, not all readers + // (one file packs many subtrees' policy — per-level transparency is + // ServeZddcFile's job, not the bundle's). if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound { t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code) } @@ -1255,7 +1260,13 @@ func TestDispatchBundleAdminWrite(t *testing.T) { if rec := do(http.MethodGet, "/.zddc.zip/.history/Proj/.zddc/log.jsonl", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "alice@x") { t.Errorf("history log=%q, want an alice@x entry", rec.Body.String()) } - // 6. Non-admin write → 404 (bundle existence-hidden to non-admins). + // 5b. Un-elevated config-editor can ALSO write the bundle — config-edit is + // standing for the bundle too, no toggle required. + if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", false, + []byte("acl:\n permissions:\n \"team@x\": rw\n")); rec.Code != http.StatusOK { + t.Errorf("un-elevated config-editor PUT bundle: status=%d, want 200 (standing)", rec.Code) + } + // 6. Non-admin write → 404 (bundle existence-hidden to non config-editors). if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "bob@x", true, []byte("x")); rec.Code != http.StatusNotFound { t.Errorf("non-admin PUT: status=%d, want 404", rec.Code) }