diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 66cf95d..8c9b751 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -243,11 +243,21 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro // 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. + // config is not WORM-protected data. + // + // Scope: this grants VerbA only, so no SINGLE decision here authorizes a + // WORM *record* write/delete/create (those need W/C/D, which stay clamped + // below, behind the elevated bypass above). It does NOT, however, make a + // WORM zone tamper-proof against its own policy owner: a config-editor who + // administers a WORM directory may edit that directory's .zddc — including + // relaxing or (via inherit:false) dropping its `worm:` marker — after + // which ordinary writes to that subtree are no longer clamped. That + // two-step demotion is intended (owning a subtree's policy includes its + // worm: declaration) and is access-logged/transparent: WORM is therefore + // tamper-EVIDENT to its config owner, not tamper-PROOF. A deployment that + // needs the marker immutable except under elevation should gate worm: + // relaxation behind IsActiveAdmin. The edit-then-write composition is + // pinned in standing_config_test.go (TestStandingConfigEdit_WormDemotionIsTwoStep). if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) { return true, nil } diff --git a/zddc/internal/policy/standing_config_test.go b/zddc/internal/policy/standing_config_test.go index 280464e..adb608e 100644 --- a/zddc/internal/policy/standing_config_test.go +++ b/zddc/internal/policy/standing_config_test.go @@ -72,3 +72,47 @@ func TestStandingConfigEdit(t *testing.T) { } } } + +// TestStandingConfigEdit_WormDemotionIsTwoStep documents the (intended) +// composition that a single-action view of the decider hides: a config-editor +// who administers a WORM zone cannot write a WORM record directly, but CAN +// demote the zone by editing its own .zddc, after which an ordinary write is +// no longer clamped. WORM is thus tamper-evident to its policy owner, not +// tamper-proof. Pinned so the behavior is an explicit, tested decision — if a +// deployment ever needs WORM markers immutable except under elevation, this is +// the test that must change alongside gating worm: relaxation behind +// IsActiveAdmin in policy.InternalDecider.Allow. +func TestStandingConfigEdit_WormDemotionIsTwoStep(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 := zddc.Principal{Email: "alice@x", Elevated: false} // config-editor, NOT elevated + + // Before — alice administers a WORM zone (admins: + a non-nil worm list). + worm := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}, Worm: []string{}}}, + HasAnyFile: true, + } + // The boundary holds: a direct WORM record write is denied unelevated... + if dec(worm, alice, ActionWrite) { + t.Error("unelevated config-editor must NOT directly write a WORM record") + } + // ...but she CAN edit the zone's policy (VerbA) — the lever for demotion. + if !dec(worm, alice, ActionAdmin) { + t.Error("config-editor should be able to edit the WORM zone's .zddc unelevated") + } + + // After — alice has rewritten that .zddc: inherit:false dropped the + // embedded worm: and her acl now grants rwcd (the post-edit cascade the + // file API persists). The subtree is no longer WORM, so her write lands — + // still unelevated. This is step two of the intended demotion. + demoted := zddc.PolicyChain{ + Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "rwcd"}}}}, + HasAnyFile: true, + } + if !dec(demoted, alice, ActionWrite) { + t.Error("after the config-editor demotes the zone, the ordinary write should be allowed") + } +}