feat(policy): config-edit is a standing permission, not elevation-gated
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) <noreply@anthropic.com>
This commit is contained in:
parent
252d3f173e
commit
bd219afeb7
6 changed files with 166 additions and 41 deletions
|
|
@ -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"), ""},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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/<party>/received and issued carry
|
||||
// `worm: {}`) is write-locked. Inside it, the effective verbs
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ func TestAllowActionFromChainP_TruthTable(t *testing.T) {
|
|||
}
|
||||
allActions := want{true, true, true, true, true}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
zddc/internal/policy/standing_config_test.go
Normal file
74
zddc/internal/policy/standing_config_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:.<dir>.admins. ELEVATION-INDEPENDENT:
|
||||
|
|
|
|||
Loading…
Reference in a new issue