From bd219afeb72aca35e1e759b7308ead986116e578 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 17:00:54 -0500 Subject: [PATCH] feat(policy): config-edit is a standing permission, not elevation-gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a .zddc you administer no longer requires toggling admin mode. Elevation becomes purely additive — it only adds the WORM/destructive overrides ("things you otherwise couldn't do"), never a prerequisite for authority you already hold. Mechanism: a new zddc.IsConfigEditor(chain, email) reports STANDING config-edit authority — being a subtree admin (admins: cascade) OR holding the `a` verb — without the elevation gate. InternalDecider.Allow grants VerbA on that basis ABOVE the WORM clamp: config is not WORM-protected data, and VerbA only ever authorises .zddc/.zddc.zip/role mutations, never write/delete of records (those stay clamped + elevation-gated). The full WORM/ACL bypass (IsActiveAdmin) is unchanged — still admins: + Elevated. This flows for free to the client: EffectiveVerbsFromChainP loops ActionAdmin through the decider, so /.profile/access + cap.has(node,'a') light up the .zddc form editor with no client change, and ServeZddcFile already gates raw .zddc reads on directory read ACL (config is visible). A standing subtree admin can thus rewrite their subtree's policy (admins:/ACL/roles) un-elevated — bounded to their scope (authority cascades down only, never up), logged, and unable to touch WORM data or secrets without elevating. That's "admin of X = owns X's policy." Tests: new TestStandingConfigEdit (decider matrix incl. WORM-transcending config-edit + data-write still gated); updated the old "un-elevated admin cannot edit .zddc" invariants (TruthTable, ZddcPut/DeleteMatrix, NoSilentBypass now scoped to WORM/out-of-scope, profile PathVerbs) to the new model. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/internal/handler/auth_invariants_test.go | 56 +++++++------- zddc/internal/handler/profilehandler_test.go | 14 ++-- zddc/internal/policy/policy.go | 14 ++++ zddc/internal/policy/principal_test.go | 32 +++++--- zddc/internal/policy/standing_config_test.go | 74 +++++++++++++++++++ zddc/internal/zddc/admin.go | 17 +++++ 6 files changed, 166 insertions(+), 41 deletions(-) create mode 100644 zddc/internal/policy/standing_config_test.go diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 0650436..c710e8d 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -114,16 +114,18 @@ func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) { } } -func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) { - // .zddc edits route through the decider as ActionAdmin. The bypass - // for elevated admins fires only when Principal.Elevated is true. - // Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated - // super-admin must return Forbidden. +func TestInvariant_UnelevatedAdminCanEditZddc(t *testing.T) { + // Config-edit is a STANDING permission: .zddc edits route through the + // decider as ActionAdmin, which IsConfigEditor grants to a subtree + // admin WITHOUT elevation (elevation is reserved for the WORM/ + // destructive overrides — see TestInvariant_UnelevatedAdminNoSilentBypass). + // Exercised at the HTTP boundary: a PUT to a .zddc the principal + // administers, from an un-elevated admin, must succeed. cfg, _ := invariantsFixture(t) target := "/Project-1/archive/Acme/working/.zddc" rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "") - if rec.Code != http.StatusForbidden { - t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { + t.Fatalf("un-elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String()) } } @@ -331,25 +333,30 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) { who principal want int }{ - // Root .zddc + // Root .zddc. Config-edit is standing for an admin OF that path: the + // root super-admin edits the root .zddc un-elevated; a subtree admin + // (alice) does NOT administer root, so she's denied either way. {"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok}, - {"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den}, + {"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, ok}, {"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den}, {"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den}, {"non-admin → root .zddc", "/.zddc", nonAdmin, den}, {"anonymous → root .zddc", "/.zddc", anon, den}, - // Project .zddc (no on-disk file yet — PUT creates it) + // Project .zddc (no on-disk file yet — PUT creates it). The root admin + // administers all subtrees (cascade), so standing-edits it un-elevated; + // alice's admin scope is below this path, so she's out-of-scope. {"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated}, - {"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den}, + {"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, http.StatusCreated}, {"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den}, {"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den}, - // Subtree .zddc (alice administers this subtree) + // Subtree .zddc (alice administers this subtree). Both the root admin + // and alice standing-edit it un-elevated; non-admins/anon denied. {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok}, - {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den}, + {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, ok}, {"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok}, - {"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den}, + {"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, ok}, {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den}, {"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den}, } @@ -392,10 +399,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) { who principal want int }{ + // .zddc DELETE is also ActionAdmin → standing config-edit within scope. {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent}, - {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden}, + {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusNoContent}, {"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent}, - {"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden}, + {"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusNoContent}, {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden}, } @@ -413,11 +421,13 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) { // ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ────── // TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the -// elevation gate. For every (admin-flavour × action) tuple, an -// un-elevated admin must behave exactly like a non-admin: they may -// only do what an explicit ACL grant permits. The fixture's admin and -// alice both have NO baseline ACL grant outside their admin scope, so -// every action below MUST 403 — any pass indicates a bypass leak. +// elevation gate, scoped to what elevation actually guards now: the +// WORM/destructive overrides. Config-edit (ActionAdmin on .zddc) is a +// STANDING permission and is exercised separately (ZddcPutMatrix / +// ZddcDeleteMatrix / UnelevatedAdminCanEditZddc) — it's deliberately NOT +// here. For every (admin-flavour × probe) below, an un-elevated admin must +// behave exactly like a non-admin: WORM records and other principals' homes +// stay off-limits without elevation. Any pass indicates a bypass leak. func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) { cfg, _ := invariantsFixture(t) type op struct { @@ -427,10 +437,6 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) { op string } probes := []op{ - // .zddc writes (ActionAdmin) - {http.MethodPut, "/.zddc", []byte("title: x\n"), ""}, - {http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""}, - {http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""}, // WORM writes (ActionWrite / ActionCreate stripped) {http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""}, {http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""}, diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 6a4a207..8e89d58 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -553,13 +553,15 @@ acl: t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant) } - // Un-elevated admin: bypass not active, so explicit verbs are - // whatever ACL granted (here: nothing — admin@ has no permissions - // entry, only an admins: entry). PathCanElevateGrant tells the - // client "elevation would unlock rwcda". + // Un-elevated admin: the WORM/destructive bypass is not active, but + // config-edit is a STANDING permission — being in the admins: cascade + // grants `a` (edit .zddc/.zddc.zip/roles) without elevating. So the + // explicit verbs are exactly "a" even though admin@ has no acl + // permissions entry. PathCanElevateGrant tells the client "elevation + // would unlock the rest (rwcda)". adminUn := fetch("admin@example.com", false) - if adminUn.PathVerbs != "" { - t.Errorf("un-elevated admin PathVerbs = %q, want empty (no explicit grant)", adminUn.PathVerbs) + if adminUn.PathVerbs != "a" { + t.Errorf("un-elevated admin PathVerbs = %q, want \"a\" (standing config-edit)", adminUn.PathVerbs) } if adminUn.PathIsAdmin { t.Errorf("un-elevated admin PathIsAdmin = true, want false") diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 1ea2a0f..ac44fef 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -235,6 +235,20 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro return true, nil } + // Standing config-edit. Authority to mutate configuration (.zddc / + // .zddc.zip / role definitions — the only actions that map to VerbA) + // is a STANDING permission: a subtree admin (admins: cascade) or a + // holder of the `a` verb may edit the config of subtrees they + // administer WITHOUT elevating. This sits ABOVE the WORM clamp because + // config is not WORM-protected data — and it only ever grants VerbA, + // so it can never write/delete/create WORM *records* (those need + // W/C/D, which stay clamped and behind the elevated bypass above). + // Elevation is thus purely additive: it adds the WORM/destructive + // overrides, never gating config-edit you already have authority for. + if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) { + return true, nil + } + // WORM zone: a directory whose cascade declares `worm:` (see // internal/zddc/defaults/ — archive//received and issued carry // `worm: {}`) is write-locked. Inside it, the effective verbs diff --git a/zddc/internal/policy/principal_test.go b/zddc/internal/policy/principal_test.go index f39c65b..5989fd8 100644 --- a/zddc/internal/policy/principal_test.go +++ b/zddc/internal/policy/principal_test.go @@ -53,7 +53,8 @@ func TestAllowActionFromChainP_TruthTable(t *testing.T) { read, write, create, deleteV, adminV bool } allActions := want{true, true, true, true, true} - noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a` + noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a` + configOnly := want{adminV: true} // standing config-edit, nothing else cases := []struct { name string @@ -76,20 +77,21 @@ func TestAllowActionFromChainP_TruthTable(t *testing.T) { }, // ─── ELEVATION GATE ───────────────────────────────────────── - // An admin who hasn't elevated MUST be treated as a normal - // user. They don't carry any baseline ACL grant in this - // fixture, so every action is denied. + // An admin who hasn't elevated gets the WORM/destructive bypass + // on NOTHING — but config-edit (the `a` verb) is a STANDING + // permission, so ActionAdmin is allowed while r/w/c/d (no ACL + // grant in this fixture) stay denied. Elevation is additive. { - name: "root admin NOT elevated → no bypass, no ACL grant → all denied", + name: "root admin NOT elevated → standing config-edit only", email: "root@example.com", elevated: false, - want: want{}, + want: configOnly, }, { - name: "subtree admin NOT elevated → no bypass, no ACL grant → all denied", + name: "subtree admin NOT elevated → standing config-edit only", email: "sub@example.com", elevated: false, - want: want{}, + want: configOnly, }, // ─── NON-ADMIN PATHS ──────────────────────────────────────── @@ -267,9 +269,11 @@ func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) { }) } - // Negative control: same principal un-elevated must NOT bypass WORM. + // Negative control: same principal un-elevated must NOT bypass WORM for + // DATA ops. Write/Delete (and Create) of records stay clamped — those + // are the destructive overrides elevation exists for. pUn := zddc.Principal{Email: "root@example.com", Elevated: false} - for _, action := range []string{ActionWrite, ActionDelete, ActionAdmin} { + for _, action := range []string{ActionWrite, ActionDelete} { t.Run("un-elevated admin in WORM zone — "+action, func(t *testing.T) { got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/x", action) if got { @@ -277,4 +281,12 @@ func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) { } }) } + + // EXCEPTION: ActionAdmin (config-edit) is a STANDING permission and + // transcends the WORM clamp — a subtree admin may fix the .zddc that + // governs a WORM zone without elevating. This grants only VerbA, never + // write/delete of the WORM records themselves (asserted just above). + if got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/.zddc", ActionAdmin); !got { + t.Errorf("un-elevated admin ActionAdmin denied in WORM zone; config-edit should be standing") + } } diff --git a/zddc/internal/policy/standing_config_test.go b/zddc/internal/policy/standing_config_test.go new file mode 100644 index 0000000..280464e --- /dev/null +++ b/zddc/internal/policy/standing_config_test.go @@ -0,0 +1,74 @@ +package policy + +import ( + "context" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// TestStandingConfigEdit pins the elevation-independent config-edit model: +// a subtree admin (admins: cascade) or an `a`-verb holder may edit config +// (ActionAdmin → VerbA) WITHOUT elevating — including above a WORM clamp — +// while WORM *data* writes and the other escape hatches stay behind the +// elevated bypass. See policy.InternalDecider.Allow + zddc.IsConfigEditor. +func TestStandingConfigEdit(t *testing.T) { + d := &InternalDecider{} + dec := func(chain zddc.PolicyChain, p zddc.Principal, action string) bool { + ok, _ := AllowActionFromChainP(context.Background(), d, chain, p, "/proj/probe", action) + return ok + } + alice := func(elev bool) zddc.Principal { return zddc.Principal{Email: "alice@x", Elevated: elev} } + + // admins: [alice] — subtree admin via the cascade. + adminChain := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}}}, + HasAnyFile: true, + } + // acl: alice holds ONLY the `a` verb (config-edit, no rwcd). + aVerbChain := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "a"}}}}, + HasAnyFile: true, + } + // acl: alice holds rw but NOT a. + rwChain := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "rw"}}}}, + HasAnyFile: true, + } + // admins: [alice] AND a WORM zone (a non-nil worm list marks the zone). + wormAdminChain := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}, Worm: []string{}}}, + HasAnyFile: true, + } + + cases := []struct { + name string + chain zddc.PolicyChain + p zddc.Principal + action string + want bool + }{ + // The headline: a subtree admin edits config without the toggle. + {"subtree admin edits .zddc unelevated", adminChain, alice(false), ActionAdmin, true}, + // ...but standing config authority does NOT bleed into data writes. + {"subtree admin data-write still needs elevation", adminChain, alice(false), ActionWrite, false}, + {"subtree admin data-write WHEN elevated (bypass)", adminChain, alice(true), ActionWrite, true}, + // The `a` verb is standing config-edit on its own, independent of admins:. + {"a-verb holder edits .zddc unelevated", aVerbChain, alice(false), ActionAdmin, true}, + {"a-verb holder cannot write data", aVerbChain, alice(false), ActionWrite, false}, + // Plain write/read must NOT be able to rewrite policy (no self-escalation). + {"rw-but-not-a cannot edit .zddc", rwChain, alice(false), ActionAdmin, false}, + {"rw user can still read", rwChain, alice(false), ActionRead, true}, + // A stranger gets nothing. + {"stranger cannot edit .zddc", adminChain, zddc.Principal{Email: "mallory@x"}, ActionAdmin, false}, + // Config-edit transcends the WORM clamp (you can fix the policy that + // governs a WORM zone), but WORM data is still protected. + {"config-edit transcends WORM clamp unelevated", wormAdminChain, alice(false), ActionAdmin, true}, + {"WORM data write denied to admin unelevated", wormAdminChain, alice(false), ActionWrite, false}, + } + for _, tc := range cases { + if got := dec(tc.chain, tc.p, tc.action); got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go index 9d96ab9..e69de13 100644 --- a/zddc/internal/zddc/admin.go +++ b/zddc/internal/zddc/admin.go @@ -64,6 +64,23 @@ func IsAdminForChain(chain PolicyChain, email string) bool { return AdminLevelInChain(chain, email) >= 0 } +// IsConfigEditor reports STANDING authority to edit configuration at this +// chain — writing a .zddc / .zddc.zip / role definition (the mutations that +// map to VerbA). Authority comes from EITHER being a subtree admin (an +// admins: grant anywhere on the chain) OR holding the `a` verb in +// acl.permissions. Unlike IsSubtreeAdmin this is NOT elevation-gated: +// editing config you administer is a standing permission, not a sudo-style +// escape hatch. Elevation (IsActiveAdmin) only ADDS the WORM/destructive +// overrides on top — see policy.InternalDecider.Allow, which consults this +// for VerbA above the WORM clamp (config is not WORM-protected data, and +// VerbA never grants write/delete of records). +func IsConfigEditor(chain PolicyChain, email string) bool { + if email == "" { + return false + } + return IsAdminForChain(chain, email) || AllowedAction(chain, email, VerbA) +} + // HasAnyAdminGrant reports whether email is named as an admin somewhere // in the cascade — either the root's admins: list (super-admin) or any // subtree-admin grant via paths:..admins. ELEVATION-INDEPENDENT: