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:
parent
bd219afeb7
commit
70591dcfa6
3 changed files with 56 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue