feat(server,browse): .zddc.zip bundle visible+editable to standing config-editors

The config bundle followed the old elevation gate: only an *elevated* admin
could browse or edit it. Bring it in line with the standing config-edit
model — a subtree admin / `a`-verb holder over the bundle's directory may
browse AND edit it without toggling. Elevation stays purely additive.

activeAdminForBundle → configEditorForBundle (zddc.IsConfigEditor, no
Elevated). Gates both the existence-hiding visibility check and the
ServeZipWrite path. Deliberately scoped to config-EDITORS, not all readers:
one .zddc.zip packs many subtrees' policy into a single file, so wide read
would leak a tightened subtree's rules — per-level transparency is served
by ServeZddcFile (already read-ACL'd) instead.

Client: isEditableZipMember drops the isElevated() check — the server gates
bundle visibility on config-edit authority, so if a member is visible the
session can edit it.

Tests: TestDispatchBundleAdminView now expects an un-elevated admin to SEE
the bundle (non-editor reader still 404); TestDispatchBundleAdminWrite adds
an un-elevated config-editor write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 17:05:34 -05:00
parent bd219afeb7
commit 70591dcfa6
3 changed files with 56 additions and 36 deletions

View file

@ -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

View file

@ -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 <bundle>/<member>) 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 <bundle>/<member>) 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_ROOT>/.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 <bundle>/ lists its members (the zip-as-directory
// intercept below), GET <bundle>/member extracts one, and a bare
// GET <bundle> 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 <bundle>/ lists
// its members (the zip-as-directory intercept below), GET <bundle>/member
// extracts one, and a bare GET <bundle> 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
}

View file

@ -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)
}