From 9271a195242b4661880e9f450049c9f916d7e247 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:16:21 -0700 Subject: [PATCH 1/2] kernel-scope: key liveness is the egress gate's for every plane, with no per-plane switch; one scope lattice, one admin matrix; the unconsulted policy lookup and the unmounted refusal helpers are deleted (Q128) HIGH: EgressSubject::REQUIRE_LIVE_KEY let a plane switch off the caller-key liveness check, and one plane shipped with it off, so a key past its own expires_at still had busbar's outbound credential spent for it there while its sibling refused it (THE DESIGN s1: every plane gets every capability). The const is deleted and authorise checks liveness unconditionally. RED test: every_plane_gets_the_liveness_check_and_none_can_switch_it_off (replaces a_plane_whose_policy_omits_liveness_does_not_have_it_imposed, which asserted the opt-out). LOWs: Scope/Grants are re-exported from busbar_contract::authz, their one home, and the lattice tests move there with them. The kernel admin gate's required_scope calls admin_required_scope, so the admin matrix has one enforced copy. PolicyView/required_scope and the root's ScopePolicy (built empty, read by nothing) are deleted, as are the never-mounted EgressRefusal::audit_resource/caller. Stale prose corrected; no plane's grant kinds named in the kernel crate. --- crates/busbar-a2a/src/a2a/creds.rs | 8 +- crates/busbar-contract/src/authz.rs | 4 + .../busbar-contract/src/tests/authz_tests.rs | 244 ++++++++++++++++++ crates/busbar-kernel-scope/Cargo.toml | 21 +- crates/busbar-kernel-scope/src/egress.rs | 61 +---- crates/busbar-kernel-scope/src/lib.rs | 221 +++------------- .../busbar-kernel-scope/src/tests/bitset.rs | 67 ----- .../busbar-kernel-scope/src/tests/egress.rs | 60 +++-- crates/busbar-kernel-scope/src/tests/mod.rs | 92 +------ .../src/tests/narrowing.rs | 130 ---------- crates/busbar-kernel/src/admin/gate.rs | 21 +- .../src/audit/tests/chain_tests.rs | 14 +- crates/busbar/src/root/kernel.rs | 7 - crates/busbar/src/root/policy.rs | 64 +---- crates/busbar/src/root/tests/kernel.rs | 7 +- crates/busbar/src/root/tests/policy.rs | 66 ----- 16 files changed, 359 insertions(+), 728 deletions(-) create mode 100644 crates/busbar-contract/src/tests/authz_tests.rs delete mode 100644 crates/busbar-kernel-scope/src/tests/bitset.rs delete mode 100644 crates/busbar-kernel-scope/src/tests/narrowing.rs diff --git a/crates/busbar-a2a/src/a2a/creds.rs b/crates/busbar-a2a/src/a2a/creds.rs index f6cd92048a..77eaeebe88 100644 --- a/crates/busbar-a2a/src/a2a/creds.rs +++ b/crates/busbar-a2a/src/a2a/creds.rs @@ -94,16 +94,10 @@ pub(crate) struct AgentEgress<'a> { agent_id: &'a str, } -/// WHAT THIS PLANE SUPPLIES TO THE CORE GATE: one grant, and the key-liveness rule. +/// WHAT THIS PLANE SUPPLIES TO THE CORE GATE: one grant. Key liveness is the gate's, for every plane. impl EgressSubject for AgentEgress<'_> { type Grant = AgentGrant; - /// TRUE, and it stays true: a key that may not authenticate may certainly not cause a credential - /// to be minted, and a LEASE outliving the key that occasioned it is a hop nobody's grant covers. - /// The sibling MCP plane sets this FALSE, which is the divergence the unification wrote down - /// rather than introduced — see `mcp/client/egress.rs`. - const REQUIRE_LIVE_KEY: bool = true; - fn grants_required(&self) -> Vec> { vec![Requirement { grant: AgentGrant::Agent, diff --git a/crates/busbar-contract/src/authz.rs b/crates/busbar-contract/src/authz.rs index 8b3c01c8a9..dcf315848a 100644 --- a/crates/busbar-contract/src/authz.rs +++ b/crates/busbar-contract/src/authz.rs @@ -140,3 +140,7 @@ impl Grants { self.0 & s.bit() != 0 } } + +#[cfg(test)] +#[path = "tests/authz_tests.rs"] +mod tests; diff --git a/crates/busbar-contract/src/tests/authz_tests.rs b/crates/busbar-contract/src/tests/authz_tests.rs new file mode 100644 index 0000000000..f3ef69a934 --- /dev/null +++ b/crates/busbar-contract/src/tests/authz_tests.rs @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The two-rung authorization lattice: its bit layout, its chain, and the laws its ceiling obeys. +//! Moved with the lattice's last copy from `busbar-kernel-scope` (Q128 kernel-scope: one home per +//! fact), so the one home is the one that is proven. + +use super::*; + +// ══ THE BITSET LAYOUT ════════════════════════════════════════════════════════════════════════════ + +// The [`Grants`] bitset layout itself. +// +// `Grants` is a bitset whose meaning is POSITIONAL: a scope's bit is `1 << its index in +// `Scope::ALL``, and `Grants::contains` reads that same bit back. Every other test in this crate +// exercises the pair together, so any assignment that is internally consistent — including one +// that hands `ReadOnly` the rung `Full` should have had — satisfies all of them. Two variants make +// a swap a bijection, so a swap is invisible from the outside and stays invisible right up until a +// third rung is added, at which point the swap stops being a bijection and starts being a scope +// confusion. So the layout is pinned here directly, on the private constructor, rather than left to +// be inferred from behaviour that cannot see it. + +/// Each scope's bit is the single bit at its own index in `Scope::ALL` — `ReadOnly` at the bottom +/// rung (bit 0), `Full` above it (bit 1) — and no two scopes share one. +#[test] +fn each_scopes_bit_is_its_own_index_in_the_declared_chain() { + assert_eq!(Scope::ReadOnly.bit(), 0b01, "ReadOnly is the bottom rung"); + assert_eq!(Scope::Full.bit(), 0b10, "Full is the rung above it"); + + for (i, s) in Scope::ALL.iter().enumerate() { + assert_eq!( + s.bit(), + 1u8 << i, + "{s:?} is at index {i} of Scope::ALL, so its bit is 1 << {i}" + ); + assert_eq!(s.bit().count_ones(), 1, "{s:?} occupies exactly one bit"); + } + + for a in Scope::ALL { + for b in Scope::ALL { + assert_eq!( + a.bit() == b.bit(), + a == b, + "distinct scopes must not share a bit: {a:?} vs {b:?}" + ); + } + } +} + +/// The bit layout is what `Grants` stores, so pinning the bits pins the set: a single-scope grant +/// holds that scope and nothing else, and the union holds both. +#[test] +fn grants_hold_exactly_the_bits_of_the_scopes_put_in_them() { + assert!(Grants::of(Scope::ReadOnly).contains(Scope::ReadOnly)); + assert!( + !Grants::of(Scope::ReadOnly).contains(Scope::Full), + "a read-only grant does not hold full" + ); + assert!(Grants::of(Scope::Full).contains(Scope::Full)); + assert!( + !Grants::of(Scope::Full).contains(Scope::ReadOnly), + "a full grant holds `full`; it SATISFIES a read, which is `allows`, not `contains`" + ); + + let both = Grants::of(Scope::ReadOnly).with(Scope::Full); + assert!(both.contains(Scope::ReadOnly)); + assert!(both.contains(Scope::Full)); + + assert!( + Scope::ALL.iter().all(|s| !Grants::default().contains(*s)), + "the empty grant holds nothing" + ); +} + +// ══ THE CHAIN ═══════════════════════════════════════════════════════════════════════════════════ + +/// `parse` drops the retired delegated tokens; `read-only`/`full` round-trip. +#[test] +fn scope_parse_drops_retired_tokens() { + assert!(Scope::parse("mint").is_none()); + assert!(Scope::parse("hooks-register").is_none()); + assert!(Scope::parse("bogus").is_none()); + assert_eq!(Scope::parse("read-only"), Some(Scope::ReadOnly)); + assert_eq!(Scope::parse("full"), Some(Scope::Full)); + assert_eq!(Scope::ReadOnly.as_str(), "read-only"); + assert_eq!(Scope::Full.as_str(), "full"); +} + +/// The two-rung chain: `ReadOnly` does not satisfy a `Full` requirement, `Full` satisfies both, and +/// a `Full` grant capped by a `ReadOnly` ceiling collapses to read-only. +#[test] +fn readonly_not_allow_full_full_allows_readonly() { + assert!(!Scope::ReadOnly.allows(Scope::Full)); + assert!(Scope::ReadOnly.allows(Scope::ReadOnly)); + assert!(Scope::Full.allows(Scope::ReadOnly)); + assert!(Scope::Full.allows(Scope::Full)); + + let capped = Grants::of(Scope::Full).capped_by(Scope::ReadOnly); + assert!(capped.allows(Scope::ReadOnly)); + assert!(!capped.allows(Scope::Full)); + + for a in Scope::ALL { + for b in Scope::ALL { + let union = Grants::of(a).with(b); + for n in Scope::ALL { + assert_eq!( + union.allows(n), + a.allows(n) || b.allows(n), + "Grants::of({a:?}).with({b:?}).allows({n:?})" + ); + } + } + } + assert_eq!(Scope::Full.meet(Scope::ReadOnly), Scope::ReadOnly); + assert!(Scope::Full.dominates(Scope::ReadOnly)); + assert!(!Scope::ReadOnly.dominates(Scope::Full)); +} + +// ══ NARROWING IS MONOTONE ════════════════════════════════════════════════════════════════════════ +// Narrowing is MONOTONE: applying a ceiling never authorizes anything the uncapped grants did not. +// +// A module ceiling (`max_admin_scope:`) is the operator's statement that whatever the role bindings +// union to, this module's principals may not exceed it. That is only a ceiling if it can subtract +// and never add. The existing cases check one instance of it — a `Full` grant capped to `ReadOnly` +// collapses — which is the case where the answer is obvious. What is pinned here is the property +// over the WHOLE lattice, so a ceiling operator that got an incomparable pair backwards, or that +// defaulted to the top rung instead of the bottom, has nowhere to hide. +// +// Stated as three laws, each checked against every grant set and every ceiling the chain admits: +// +// 1. **Narrowing only removes.** Anything the capped grants authorize, the uncapped grants already +// authorized. This is the security direction, and it is the one an escalation would break. +// 2. **Narrowing is idempotent.** Applying the same ceiling twice is applying it once. A ceiling +// that kept moving would make a principal's authority depend on how many times the config was +// reloaded. +// 3. **Narrowing is order-independent.** Two ceilings applied in either order give the same +// answer, so a principal matching two ceilinged bindings has one authority rather than one per +// evaluation order. + +/// Every grant set over the two-rung chain: nothing, each scope alone, and both. +fn every_grants() -> Vec { + let mut out = vec![Grants::default()]; + for a in Scope::ALL { + out.push(Grants::of(a)); + for b in Scope::ALL { + out.push(Grants::of(a).with(b)); + } + } + out +} + +/// Law 1, the security direction: a ceiling can only take authority away. +#[test] +fn a_ceiling_never_authorizes_anything_the_uncapped_grants_did_not() { + for held in every_grants() { + for cap in Scope::ALL { + let capped = held.capped_by(cap); + for needed in Scope::ALL { + if capped.allows(needed) { + assert!( + held.allows(needed), + "{capped:?} (= {held:?} capped by {cap:?}) authorizes {needed:?}, \ + which the uncapped grants did not — a ceiling that ESCALATES" + ); + } + } + // And the ceiling is a ceiling: nothing survives it that the ceiling itself does not + // authorize, which is the whole of what an operator writing `max_admin_scope:` is + // asking for. + for needed in Scope::ALL { + if capped.allows(needed) { + assert!( + cap.allows(needed), + "{capped:?} authorizes {needed:?}, which the ceiling {cap:?} does not" + ); + } + } + } + } +} + +/// Law 2: applying a ceiling twice is applying it once. +#[test] +fn a_ceiling_applied_twice_is_the_ceiling_applied_once() { + for held in every_grants() { + for cap in Scope::ALL { + let once = held.capped_by(cap); + assert_eq!( + once.capped_by(cap), + once, + "{held:?} capped by {cap:?} is not stable under a second application" + ); + } + } +} + +/// Law 3: two ceilings commute, so a principal's authority does not depend on evaluation order. +#[test] +fn two_ceilings_give_the_same_answer_in_either_order() { + for held in every_grants() { + for a in Scope::ALL { + for b in Scope::ALL { + assert_eq!( + held.capped_by(a).capped_by(b), + held.capped_by(b).capped_by(a), + "{held:?} under ceilings {a:?} and {b:?} depends on the order they are applied" + ); + } + } + } +} + +/// The union direction, stated as the other half of the same property: unioning role bindings only +/// ever ADDS, and the union of two roles authorizes exactly what one or the other did. +/// +/// The two together are what make the fold over a principal's bindings well-defined: union up, then +/// meet down, and neither step can be reordered into an escalation. +#[test] +fn a_union_authorizes_exactly_what_one_or_the_other_role_authorized() { + for held in every_grants() { + for added in Scope::ALL { + let union = held.with(added); + for needed in Scope::ALL { + assert_eq!( + union.allows(needed), + held.allows(needed) || Grants::of(added).allows(needed), + "{held:?} unioned with {added:?} on {needed:?}" + ); + } + assert!( + union.contains(added), + "a union keeps the scope it added: {held:?} + {added:?}" + ); + for s in Scope::ALL { + if held.contains(s) { + assert!( + union.contains(s), + "a union keeps what was already held: {held:?} + {added:?} lost {s:?}" + ); + } + } + } + } +} diff --git a/crates/busbar-kernel-scope/Cargo.toml b/crates/busbar-kernel-scope/Cargo.toml index 288fb79866..78983030ab 100644 --- a/crates/busbar-kernel-scope/Cargo.toml +++ b/crates/busbar-kernel-scope/Cargo.toml @@ -1,23 +1,22 @@ -# busbar-kernel-scope — the APPROVE step: required scope from (claim, op_class) in Policy. +# busbar-kernel-scope — the APPROVE step's scope half: the admin-API scope matrix and the egress +# grant gate. # # WHY A SEPARATE CRATE. The kernel loop's APPROVE step asks one question — does the principal's -# held scope cover what this operation needs — and answers it with resource locators only; a hook's -# veto is a separate, closed code, and the first veto at any seat wins. None of that needs a wire -# format, an HTTP framework, or a store: it is a pure function over data, so it gets its own crate -# with `busbar-contract` as its only workspace dependency, exactly like the door does not need to know -# what building it is bolted to. +# held scope cover what this operation needs. None of that needs a wire format, an HTTP framework, +# or a store: it is a pure function over data, so it gets its own crate with `busbar-contract` as its +# only workspace dependency. # -# WHAT LIVES HERE. The two-rung `Scope` chain (`ReadOnly`/`Full`), the `Grants` bitset roles -# aggregate into, `required_scope(method, path)` — the admin-API scope matrix ported verbatim from -# 1.5.5's `openapi.json` at the tag (66 operations, 34 read-only / 32 full) — and the `PolicyView` -# lookup a native plane's `(claim, op_class)` entries are read through. +# WHAT LIVES HERE. `admin_required_scope(method, path)` — THE admin-API scope matrix, ported from +# 1.5.5's `openapi.json` at the tag (66 operations, 34 read-only / 32 full), which the kernel's admin +# gate calls — the table it is proven against, and the egress grant gate. The two-rung `Scope` chain +# and the `Grants` bitset are re-exported from `busbar_contract::authz`, their one home. [package] name = "busbar-kernel-scope" version = "1.6.0" publish = false # internal workspace crate; never goes to crates.io edition = "2021" rust-version = "1.97" -description = "The APPROVE step: required scope from (claim, op_class) in Policy, and the 1.5.5 admin scope table as data." +description = "The APPROVE step's scope half: the admin-API scope matrix, the 1.5.5 admin scope table as data, and the egress grant gate." license = "Apache-2.0" [lib] diff --git a/crates/busbar-kernel-scope/src/egress.rs b/crates/busbar-kernel-scope/src/egress.rs index 10a34058c2..1669e5dc50 100644 --- a/crates/busbar-kernel-scope/src/egress.rs +++ b/crates/busbar-kernel-scope/src/egress.rs @@ -27,8 +27,8 @@ pub struct Requirement { pub value: String, } -/// WHAT A CONSUMER SUPPLIES: the grants an egress on this subject requires, and whether the -/// consumer's key-liveness rule applies. Everything else is [`authorise`]'s business. +/// WHAT A CONSUMER SUPPLIES: the grants an egress on this subject requires. Everything else — +/// including the caller key's liveness, which every plane gets alike — is [`authorise`]'s business. pub trait EgressSubject { /// The consumer's own enumeration of the checks — ONE variant per requirement. An enum rather /// than a string so the consumer's refusal conversion is exhaustive over its own checks: a @@ -36,16 +36,6 @@ pub trait EgressSubject { /// silently reused. type Grant: Copy + std::fmt::Debug; - /// Whether the caller's KEY must still be live (not tombstoned, enabled, not expired) for this - /// consumer's egress. - /// - /// A CONST rather than a runtime flag, because it is a property of the consumer and not of the - /// request. Different consumers can legitimately disagree on this, and this const is where that - /// divergence is stated rather than being an accident of which file a reader opens. Unifying the - /// gate does not force either answer: a gate that quietly started refusing more for one - /// consumer, or less, would be a behaviour change wearing a refactor's clothes. - const REQUIRE_LIVE_KEY: bool; - /// EVERY grant that must pass, IN THE ORDER THEY ARE CHECKED. All of them must pass; the first /// that fails is the refusal reported, so the order is the operator-facing diagnosis order and /// is part of the contract rather than an implementation detail. @@ -55,7 +45,7 @@ pub trait EgressSubject { /// WHY NO OUTBOUND CREDENTIAL MAY BE SELECTED FOR THIS CALLER. /// /// Core's refusal, which every plane converts into its own wording with a TOTAL `From`. Totality is -/// deliberate: a refusal this enum grows later must be given a sentence on every plane rather than +/// deliberate: a refusal this enum grows must be given a sentence on every plane rather than /// being folded silently into a nearby arm, and an exhaustive match is what forces that. #[derive(Clone, Debug, PartialEq, Eq)] pub enum EgressRefusal { @@ -103,37 +93,6 @@ impl std::fmt::Display for EgressRefusal { } } -// NOT MOUNTED YET, deliberately named rather than omitted. Existing consumers each audit their own -// egress refusal at their ingress with their own resource spelling; those call sites are what these -// methods exist to replace, and the replacement is a separate change because it edits an ingress -// module. Present now because the audit VOCABULARY is the part that needs to be shared, and a -// consumer that arrives later must find it here rather than invent a third spelling. Exercised by -// `tests/egress.rs`. -impl EgressRefusal { - /// The refused destination in the vocabulary the AUDIT ring speaks: `:`, or - /// the key itself when the refusal is about the key rather than the destination. - /// - /// Here rather than on each consumer because an audit row that is spelled one way per consumer - /// is an audit an auditor cannot read across — a single shared spelling keeps the audit trail - /// legible no matter which consumer produced the refusal. - pub fn audit_resource(&self) -> String { - match self { - EgressRefusal::KeyNotLive { caller } => format!("key:{caller}"), - EgressRefusal::NoGrant { - scope_kind, value, .. - } => format!("{scope_kind}:{value}"), - } - } - - /// WHO was refused. Every refusal names the caller, because an unattributable denial is a denial - /// nobody can act on. - pub fn caller(&self) -> &str { - match self { - EgressRefusal::KeyNotLive { caller } | EgressRefusal::NoGrant { caller, .. } => caller, - } - } -} - /// PROOF THAT THE INBOUND PRINCIPAL IS ITSELF AUTHORISED FOR THIS DESTINATION. /// /// The whole value of this type is that it cannot be built anywhere but [`authorise`]: the field is @@ -157,11 +116,13 @@ impl EgressGrant { /// /// Two checks and an order: /// -/// 1. LIVENESS, when the plane requires it, FIRST — a key that may not authenticate at all is -/// refused as itself rather than as a missing grant, because those are different operator -/// actions. +/// 1. LIVENESS, FIRST, for every plane alike (THE DESIGN §1: every plane gets every capability) — +/// a key that is tombstoned, disabled or past its own `expires_at` at `now` may not cause a +/// credential to be minted, and is refused as itself rather than as a missing grant, because +/// those are different operator actions. `now` is the caller's clock in the key's unit (unix +/// seconds); a consumer passing a clock it does not have would be switching this check off. /// 2. EVERY requirement, in the plane's declared order. ALL must pass. Requiring all of them is what -/// keeps a coarse grant (`mcp_server`) from silently becoming a fine one (`mcp_tool`). +/// keeps a coarse grant from silently becoming a fine one. /// /// `VirtualKey::scope_allowed` is reused verbatim rather than reimplemented: its cross-kind /// fail-closed semantics are frozen at 1.5.3 (an OMITTED scope list is a wildcard, an EMPTY one is @@ -171,9 +132,7 @@ pub fn authorise( subject: S, now: u64, ) -> Result, EgressRefusal> { - if S::REQUIRE_LIVE_KEY - && (!caller.is_live() || !caller.enabled || caller.expires_at.is_some_and(|exp| now >= exp)) - { + if !caller.is_live() || !caller.enabled || caller.expires_at.is_some_and(|exp| now >= exp) { return Err(EgressRefusal::KeyNotLive { caller: caller.id.clone(), }); diff --git a/crates/busbar-kernel-scope/src/lib.rs b/crates/busbar-kernel-scope/src/lib.rs index 28fde4758e..109af10bb1 100644 --- a/crates/busbar-kernel-scope/src/lib.rs +++ b/crates/busbar-kernel-scope/src/lib.rs @@ -1,168 +1,34 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! # busbar-kernel-scope — the APPROVE step +//! # busbar-kernel-scope — the APPROVE step's scope half //! -//! The kernel loop's third step asks one question: does the principal hold enough scope for what -//! this operation needs? The APPROVE step looks up the required scope from `(claim, op_class)` in -//! Policy, the plane hands back resource locators only (never a decision), and a hook's veto is a -//! separate, closed code where the first veto at any seat wins. This crate is the "does the -//! principal hold enough scope" half of that: a small, pure data model with no I/O, no wire format -//! and no store, so it can be proven correct on its own and reused by every transport that needs an -//! authorization answer (today: the admin API). +//! The kernel loop's approve step asks one question: does the principal hold enough scope for what +//! this operation needs? This crate is that question for the admin API, and the egress grant gate: +//! a small, pure data model with no I/O, no wire format and no store, so it can be proven correct +//! on its own. //! //! ## What is in here //! -//! - [`Scope`] — the two-rung authorization chain (`ReadOnly` at the bottom, `Full` at the top), -//! ported byte-for-byte from 1.5.5's admin API. -//! - [`Grants`] — the set a principal actually holds, once every role binding it matches has been -//! unioned together and any ceiling has been applied. -//! - [`admin_required_scope`] — the admin-API scope matrix: derived from HTTP method and path alone -//! (never the request body, so a crafted request cannot escalate), matching 1.5.5's -//! `admin_required_scope(method, path)` operation-for-operation. +//! - [`Scope`] and [`Grants`] — the two-rung authorization chain and the set a principal holds, +//! re-exported from their one home, `busbar_contract::authz`. +//! - [`admin_required_scope`] — THE admin-API scope matrix: derived from HTTP method and path alone +//! (never the request body, so a crafted request cannot escalate). It is the only copy: the +//! kernel's admin gate and the root's approve step both call it. //! - [`ADMIN_SCOPE_TABLE`] — the 66 operations that matrix was mechanically extracted from at the //! 1.5.5 tag (`v1.5.5`, `crates/busbar/src/admin/v1/json/openapi.json`), kept here as DATA so the //! rule above can be proven against every one of them instead of a hand-picked sample. -//! -//! ## What is deliberately absent -//! -//! The full APPROVE step also resolves `(claim, op_class)` against `Policy` to find the required -//! scope in the first place, evaluates hook facts, and picks resource locators — all of that reaches -//! into the plane host, the policy store and the hook seat machinery, none of which this crate -//! depends on. What is here is the part that is pure data plus a pure function: the admin-API scope -//! table (a concrete, already-migrated instance of "required scope from (claim, op_class)") and the -//! two-rung authorization chain every caller of that lookup is checked against. -//! - [`PolicyView`] and [`required_scope`] — the `(claim, op_class)` lookup a 1.6.0-native plane's -//! `Policy` entries are read through. The contract crate now carries a claim's name, so the pair -//! the design makes the lookup key can finally be spelled; without the claim half, a native plane -//! had no way to be scoped at all. +//! - [`egress`] — the egress gate: a virtual-key grant check before busbar spends its own outbound +//! credential. #![forbid(unsafe_code)] #![deny(missing_docs)] -use busbar_contract::{ClaimKey, OpClassId}; - /// The egress gate: a virtual-key grant check before busbar spends its own outbound credential. pub mod egress; -/// The built-in authorization scopes — a strict two-rung chain: `ReadOnly` at the bottom, `Full` at -/// the top. Authorization is checked on the PRINCIPAL per endpoint and is NEVER derived from the -/// request body, so a crafted request cannot escalate. -/// -/// 1.5.2 collapsed the former four-variant diamond (delegated `HooksRegister`/`Mint` sibling scopes) -/// down to these two: every read is `ReadOnly`, every mutation is `Full`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Scope { - /// Every read (`GET`/`HEAD`) plus the stateless dry-run `POST`s that lint without mutating - /// anything. - ReadOnly, - /// Everything: every mutation. - Full, -} - -impl Scope { - /// The stable wire token for this scope. - pub fn as_str(self) -> &'static str { - match self { - Scope::ReadOnly => "read-only", - Scope::Full => "full", - } - } - - /// Parse a config-side scope token (`role_bindings...admin_scope`, - /// `max_admin_scope:`). `None` = unknown token: a caller treats that as no grant (fail closed). - /// The retired `hooks-register`/`mint` tokens parse to `None` too. - pub fn parse(token: &str) -> Option { - match token { - "read-only" => Some(Scope::ReadOnly), - "full" => Some(Scope::Full), - _ => None, - } - } - - /// Whether a principal holding `self` may call an endpoint requiring `needed`. A strict chain: - /// `ReadOnly` is satisfied by anything (every grant can read); `Full` is satisfied only by - /// `Full`. - pub fn allows(self, needed: Scope) -> bool { - match needed { - Scope::ReadOnly => true, - Scope::Full => self == Scope::Full, - } - } - - /// Every scope, for the closure operations below. Adding a variant means adding it here too. - const ALL: [Scope; 2] = [Scope::ReadOnly, Scope::Full]; - - /// This scope's bit in a [`Grants`] bitset. - fn bit(self) -> u8 { - 1u8 << Scope::ALL - .iter() - .position(|s| *s == self) - .expect("Scope::ALL enumerates every variant") - } - - /// Does holding `self` confer everything holding `other` confers? Derived from `allows`, never - /// a second hand-written table. - fn dominates(self, other: Scope) -> bool { - Scope::ALL - .iter() - .all(|n| !other.allows(*n) || self.allows(*n)) - } - - /// The greatest scope conferring no more than either operand — the ceiling operator (the meet - /// of the two-rung chain): the lower of the two, i.e. `ReadOnly` unless both are `Full`. - pub fn meet(self, other: Scope) -> Scope { - if self.dominates(other) { - other - } else if other.dominates(self) { - self - } else { - Scope::ReadOnly - } - } -} - -/// The effective authority of a principal: a SET of scopes. Roles union into it ([`Grants::with`]); -/// a module ceiling meets each member ([`Grants::capped_by`]). A bitset over `Scope::ALL` — `Copy`, -/// no allocation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Grants(u8); - -impl Grants { - /// The single-scope grant. - pub fn of(s: Scope) -> Self { - Grants(s.bit()) - } - - /// Union — add `s` to the held grants. Folding a principal's role bindings with `with` keeps - /// every scope a role grants, so two roles together keep both instead of an ordinal `max` - /// collapsing to one and losing the other. - pub fn with(self, s: Scope) -> Self { - Grants(self.0 | s.bit()) - } - - /// Pointwise `meet` against a ceiling. Each held scope is capped independently, so a principal - /// capped below one of two incomparable grants doesn't lose the other. - pub fn capped_by(self, cap: Scope) -> Self { - Scope::ALL - .iter() - .filter(|s| self.contains(**s)) - .fold(Grants::default(), |acc, s| acc.with(s.meet(cap))) - } - - /// The authorization check: does ANY held scope satisfy `needed`? - pub fn allows(self, needed: Scope) -> bool { - Scope::ALL - .iter() - .any(|s| self.contains(*s) && s.allows(needed)) - } - - /// Exact membership — for a caller that must name one specific scope, not "does this authorize - /// X". - pub fn contains(self, s: Scope) -> bool { - self.0 & s.bit() != 0 - } -} +/// The two-rung authorization chain and a principal's grant set, from their one home. +pub use busbar_contract::authz::{Grants, Scope}; /// The frozen Admin API v1 path prefix every operation in [`ADMIN_SCOPE_TABLE`] is mounted under. /// @@ -180,27 +46,25 @@ const READ_ONLY_POST_PATHS: &[&str] = &["/config/validate", "/plugins/inspect"]; /// dry-run `POST`s is `read-only`; every mutation needs `full`. Unknown methods fail closed to /// `full`. /// -/// Ported verbatim from 1.5.5's `busbar_kernel::admin::v1::contract::required_scope` (behaviourally -/// identical; the only change is that `method` is a plain string here instead of `axum::http::Method`, -/// so this crate carries no HTTP-framework dependency at all). +/// Ported from 1.5.5's `admin_required_scope(method, path)`, behaviourally identical; `method` is a +/// plain string so this crate carries no HTTP-framework dependency. The kernel's admin gate +/// (`busbar_kernel::admin::gate::required_scope`) calls this function rather than restating it. /// -/// VERBATIM MEANS VERBATIM, and three ways of being nearly-verbatim were each a defect: +/// Three ways of being nearly-identical were each a defect when this matrix had a second copy, and +/// each is still the rule: /// /// - **The method is compared EXACTLY.** `axum::http::Method`'s equality is case-sensitive, per /// RFC 9110 §9.1: the method token is case-sensitive and `get` is not `GET` — it is an EXTENSION /// method that happens to look like one. Case-folding it here let a non-canonical verb be folded -/// into `GET`/`HEAD` and DOWNGRADED to `read-only`, where the enforced matrix fails closed to -/// `full`. That is the wrong direction for an authorization matrix to differ in, whatever else +/// into `GET`/`HEAD` and DOWNGRADED to `read-only`, where 1.5.5 fails closed to `full`. That is the wrong direction for an authorization matrix to differ in, whatever else /// filters the verb first. -/// - **The dry-run paths are matched on PATH ALONE**, with no method gate, exactly as the enforced -/// matrix does. Requiring `POST` here made this copy answer `full` for a pair the enforced copy -/// answers `read-only` for — a second matrix with a second opinion, which is the one thing a -/// ported table may not be. -/// - **The query string is not part of the operation's identity.** The enforced matrix is handed -/// `uri().path()`; this one is handed the request's recorded path, which carries `?query` with -/// it, so `POST /config/validate?x=1` missed the dry-run row and demanded `full` where the -/// previous release served it to a read-only token. The plane's own verb table already cuts the -/// query before it matches (`admin_codec::verbs::find_verb`), for precisely this reason. +/// - **The dry-run paths are matched on PATH ALONE**, with no method gate, exactly as the 1.5.5 +/// 1.5.5 matrix does. Requiring `POST` would answer `full` for a pair 1.5.5 answers `read-only` +/// for. +/// - **The query string is not part of the operation's identity.** The kernel gate hands +/// `uri().path()`; the root's approve step hands the request's recorded path, which carries +/// `?query` with it, so the query is cut here: `POST /config/validate?x=1` is the dry-run row, +/// as the previous release served it to a read-only token. pub fn admin_required_scope(method: &str, path: &str) -> Scope { if method == "GET" || method == "HEAD" { return Scope::ReadOnly; @@ -228,14 +92,9 @@ pub struct AdminOperation { /// The 1.5.5 admin scope table, as data: 66 operations over 49 paths (34 `read-only`, 32 `full`), /// derived mechanically from 1.5.5's `openapi.json` at the `v1.5.5` tag -/// (`crates/busbar/src/admin/v1/json/openapi.json`'s `x-busbar-required-scope` annotations) — -/// pinned by git object hash. `POST /config/validate` and `POST /plugins/inspect` are read-only. -/// This is the "1.5.5 scope table as data" [`admin_required_scope`] is proven against, row for row, in -/// the table test. -/// -/// `// contract:` the richer `(claim, op_class) -> Scope` lookup a 1.6.0-native plane's `Policy` -/// entries would add lands here once the contract crate carries `ClaimKey`/`OpClass`; every entry -/// below is the migrated, already-closed instance of that lookup for the admin plane. +/// (`crates/busbar/src/admin/v1/json/openapi.json`'s `x-busbar-required-scope` annotations). +/// `POST /config/validate` and `POST /plugins/inspect` are read-only. This is the "1.5.5 scope +/// table as data" [`admin_required_scope`] is proven against, row for row, in the table test. pub static ADMIN_SCOPE_TABLE: &[AdminOperation] = &[ op("DELETE", "/api/v1/admin/export/{name}", Scope::Full), op("DELETE", "/api/v1/admin/groups/{name}", Scope::Full), @@ -334,25 +193,5 @@ const fn op(method: &'static str, path: &'static str, scope: Scope) -> AdminOper } } -/// Where the required scope for a claim's operation class is read from. -/// -/// A trait rather than a table, because the entries live in the sealed `Policy` the composition -/// root holds and this crate owns no policy store. The admin matrix above is one already-closed -/// instance of the same lookup, written out as data because it is byte-pinned parity surface. -pub trait PolicyView { - /// The scope this claim's operation class requires, where the policy says anything about it. - fn required_scope(&self, claim: ClaimKey, op: OpClassId) -> Option; -} - -/// The APPROVE step's lookup: the scope a claim's operation class requires. -/// -/// A pair the policy says nothing about has NO required -/// scope. That is a refusal rather than a pass: an operation nobody wrote a policy entry for has -/// not been authorized, and a plane that could be scoped by silence could be scoped by omission. -#[must_use] -pub fn required_scope(claim: ClaimKey, op: OpClassId, policy: &dyn PolicyView) -> Option { - policy.required_scope(claim, op) -} - #[cfg(test)] mod tests; diff --git a/crates/busbar-kernel-scope/src/tests/bitset.rs b/crates/busbar-kernel-scope/src/tests/bitset.rs deleted file mode 100644 index cca2ffa929..0000000000 --- a/crates/busbar-kernel-scope/src/tests/bitset.rs +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! The [`Grants`] bitset layout itself. -//! -//! `Grants` is a bitset whose meaning is POSITIONAL: a scope's bit is `1 << its index in -//! `Scope::ALL``, and `Grants::contains` reads that same bit back. Every other test in this crate -//! exercises the pair together, so any assignment that is internally consistent — including one -//! that hands `ReadOnly` the rung `Full` should have had — satisfies all of them. Two variants make -//! a swap a bijection, so a swap is invisible from the outside and stays invisible right up until a -//! third rung is added, at which point the swap stops being a bijection and starts being a scope -//! confusion. So the layout is pinned here directly, on the private constructor, rather than left to -//! be inferred from behaviour that cannot see it. - -use super::*; - -/// Each scope's bit is the single bit at its own index in `Scope::ALL` — `ReadOnly` at the bottom -/// rung (bit 0), `Full` above it (bit 1) — and no two scopes share one. -#[test] -fn each_scopes_bit_is_its_own_index_in_the_declared_chain() { - assert_eq!(Scope::ReadOnly.bit(), 0b01, "ReadOnly is the bottom rung"); - assert_eq!(Scope::Full.bit(), 0b10, "Full is the rung above it"); - - for (i, s) in Scope::ALL.iter().enumerate() { - assert_eq!( - s.bit(), - 1u8 << i, - "{s:?} is at index {i} of Scope::ALL, so its bit is 1 << {i}" - ); - assert_eq!(s.bit().count_ones(), 1, "{s:?} occupies exactly one bit"); - } - - for a in Scope::ALL { - for b in Scope::ALL { - assert_eq!( - a.bit() == b.bit(), - a == b, - "distinct scopes must not share a bit: {a:?} vs {b:?}" - ); - } - } -} - -/// The bit layout is what `Grants` stores, so pinning the bits pins the set: a single-scope grant -/// holds that scope and nothing else, and the union holds both. -#[test] -fn grants_hold_exactly_the_bits_of_the_scopes_put_in_them() { - assert!(Grants::of(Scope::ReadOnly).contains(Scope::ReadOnly)); - assert!( - !Grants::of(Scope::ReadOnly).contains(Scope::Full), - "a read-only grant does not hold full" - ); - assert!(Grants::of(Scope::Full).contains(Scope::Full)); - assert!( - !Grants::of(Scope::Full).contains(Scope::ReadOnly), - "a full grant holds `full`; it SATISFIES a read, which is `allows`, not `contains`" - ); - - let both = Grants::of(Scope::ReadOnly).with(Scope::Full); - assert!(both.contains(Scope::ReadOnly)); - assert!(both.contains(Scope::Full)); - - assert!( - Scope::ALL.iter().all(|s| !Grants::default().contains(*s)), - "the empty grant holds nothing" - ); -} diff --git a/crates/busbar-kernel-scope/src/tests/egress.rs b/crates/busbar-kernel-scope/src/tests/egress.rs index 971a93814d..decdf3aa8d 100644 --- a/crates/busbar-kernel-scope/src/tests/egress.rs +++ b/crates/busbar-kernel-scope/src/tests/egress.rs @@ -50,7 +50,6 @@ struct QueueEgress { impl EgressSubject for QueueEgress { type Grant = QueueGrant; - const REQUIRE_LIVE_KEY: bool = true; fn grants_required(&self) -> Vec> { vec![ @@ -172,13 +171,6 @@ fn a_third_plane_costs_a_grant_kind_and_nothing_else() { "the refusal must name the caller and the destination: {rendered}" ); } - - // AUDITED: the refusal carries core's one audit spelling, so no record type and no resource - // spelling is written for this plane. That it lands on core's hash chain tamper-evidently is - // `busbar-kernel`'s `audit/tests/chain_tests.rs::an_egress_refusal_is_audited_on_the_chain`. - let refusal = authorise(&key_with("k-queue", &[]), a_queue(), 0).expect_err("no grants at all"); - assert_eq!(refusal.audit_resource(), "queue_broker:kafka-prod"); - assert_eq!(refusal.caller(), "k-queue"); } // ══ THE GATE'S OWN CHECKS ════════════════════════════════════════════════════════════════════════ @@ -236,29 +228,49 @@ fn a_key_expires_at_its_expiry_rather_than_after_it() { ); } -/// A PLANE THAT DOES NOT REQUIRE LIVENESS DOES NOT GET IT IMPOSED. The MCP plane's egress has never -/// consulted key liveness, and the unification must not have quietly started: a gate that refuses -/// MORE after a refactor is still a behaviour change nobody asked for. +/// EVERY PLANE GETS THE LIVENESS CHECK, AND NO PLANE CAN SWITCH IT OFF (THE DESIGN §1: every plane +/// gets every capability; Q128 kernel-scope). The gate used to offer a per-plane `REQUIRE_LIVE_KEY` +/// opt-out and one plane shipped with it off, so a key past its own `expires_at` still had busbar's +/// outbound credential spent for it on that plane while its sibling refused the same key. A subject +/// states its grants and nothing else, and a dead key is refused for it like for any other. #[test] -fn a_plane_whose_policy_omits_liveness_does_not_have_it_imposed() { - /// The same three requirements, with the liveness rule OFF. - struct LivenessOff(QueueEgress); - impl EgressSubject for LivenessOff { +fn every_plane_gets_the_liveness_check_and_none_can_switch_it_off() { + /// A subject that states only its grants — all the trait lets a plane state. + #[derive(Debug)] + struct GrantsOnly(QueueEgress); + impl EgressSubject for GrantsOnly { type Grant = QueueGrant; - const REQUIRE_LIVE_KEY: bool = false; fn grants_required(&self) -> Vec> { self.0.grants_required() } } - let mut key = a_fully_granted_caller(); - key.enabled = false; - key.deleted_at = Some(1); - key.expires_at = Some(1); - assert!( - authorise(&key, LivenessOff(a_queue()), 1_000).is_ok(), - "a plane that does not ask for the liveness check must not receive it" - ); + for (what, mutate) in [ + ( + "disabled", + Box::new(|k: &mut busbar_contract::records::VirtualKey| k.enabled = false) + as Box, + ), + ( + "tombstoned", + Box::new(|k: &mut busbar_contract::records::VirtualKey| k.deleted_at = Some(1)), + ), + ( + "expired", + Box::new(|k: &mut busbar_contract::records::VirtualKey| k.expires_at = Some(1)), + ), + ] { + let mut key = a_fully_granted_caller(); + mutate(&mut key); + assert_eq!( + authorise(&key, GrantsOnly(a_queue()), 1_000) + .expect_err("a key that is not live obtains no grant on any plane"), + EgressRefusal::KeyNotLive { + caller: key.id.clone() + }, + "a {what} key must be refused as NOT LIVE on every plane" + ); + } } /// EVERY REQUIREMENT IS CHECKED, not just the first. A gate that stopped after one check would let a diff --git a/crates/busbar-kernel-scope/src/tests/mod.rs b/crates/busbar-kernel-scope/src/tests/mod.rs index 72136909d7..94d9b378ff 100644 --- a/crates/busbar-kernel-scope/src/tests/mod.rs +++ b/crates/busbar-kernel-scope/src/tests/mod.rs @@ -3,9 +3,7 @@ use super::*; -mod bitset; mod egress; -mod narrowing; /// The authorization matrix, ported from 1.5.5's `required_scope_matrix` test: reads (+ the two /// dry-run POSTs) are read-only, every mutation is full, unknown methods fail closed to full. @@ -53,9 +51,9 @@ fn required_scope_matrix() { /// THE PORT IS VERBATIM OR IT IS A SECOND MATRIX WITH A SECOND OPINION. /// -/// Three ways this copy used to differ from the enforced -/// `busbar_kernel::admin::v1::contract::required_scope` it claims to port, each asserted against -/// the behaviour of the `axum::http::Method`-backed original: +/// Three ways a second copy of this matrix once differed from the enforced one (there is one copy +/// now; the kernel's admin gate calls this function), each still asserted against the behaviour of +/// 1.5.5's `axum::http::Method`-backed original: /// /// 1. A NON-CANONICAL VERB IS NOT A READ. `Method`'s equality is case-sensitive (RFC 9110 §9.1): /// `get` is an EXTENSION method that merely looks like `GET`, and the enforced matrix therefore @@ -126,48 +124,6 @@ fn the_scope_matrix_matches_the_enforced_one_verbatim() { ); } -/// `parse` drops the retired delegated tokens; `read-only`/`full` round-trip. -#[test] -fn scope_parse_drops_retired_tokens() { - assert!(Scope::parse("mint").is_none()); - assert!(Scope::parse("hooks-register").is_none()); - assert!(Scope::parse("bogus").is_none()); - assert_eq!(Scope::parse("read-only"), Some(Scope::ReadOnly)); - assert_eq!(Scope::parse("full"), Some(Scope::Full)); - assert_eq!(Scope::ReadOnly.as_str(), "read-only"); - assert_eq!(Scope::Full.as_str(), "full"); -} - -/// The two-rung chain: `ReadOnly` does not satisfy a `Full` requirement, `Full` satisfies both, and -/// a `Full` grant capped by a `ReadOnly` ceiling collapses to read-only. -#[test] -fn readonly_not_allow_full_full_allows_readonly() { - assert!(!Scope::ReadOnly.allows(Scope::Full)); - assert!(Scope::ReadOnly.allows(Scope::ReadOnly)); - assert!(Scope::Full.allows(Scope::ReadOnly)); - assert!(Scope::Full.allows(Scope::Full)); - - let capped = Grants::of(Scope::Full).capped_by(Scope::ReadOnly); - assert!(capped.allows(Scope::ReadOnly)); - assert!(!capped.allows(Scope::Full)); - - for a in Scope::ALL { - for b in Scope::ALL { - let union = Grants::of(a).with(b); - for n in Scope::ALL { - assert_eq!( - union.allows(n), - a.allows(n) || b.allows(n), - "Grants::of({a:?}).with({b:?}).allows({n:?})" - ); - } - } - } - assert_eq!(Scope::Full.meet(Scope::ReadOnly), Scope::ReadOnly); - assert!(Scope::Full.dominates(Scope::ReadOnly)); - assert!(!Scope::ReadOnly.dominates(Scope::Full)); -} - /// The table test: `required_scope` reproduces every one of the 66 pinned 1.5.5 admin operations, /// and the table's own read-only/full split is exactly 34/32. #[test] @@ -218,45 +174,3 @@ fn admin_scope_table_rows_are_well_formed() { ); } } - -/// The design's own lookup key is the pair `(claim, operation class)`, and it can now be spelled. -/// -/// Until the contract crate carried a claim's name, a 1.6.0-native plane had no way to be scoped at -/// all: its operations could be declared, but nothing could say which of its claims a policy entry -/// was about. What this pins is the two answers that are not "look it up": a pair the policy says -/// nothing about has no required scope, which is a refusal rather than a pass. -#[test] -fn a_claims_operation_class_is_scoped_through_the_policy() { - use busbar_contract::{ClaimKey, OpClassId}; - - struct OnePolicy; - impl crate::PolicyView for OnePolicy { - fn required_scope(&self, claim: ClaimKey, op: OpClassId) -> Option { - match (claim.as_str(), op.as_str()) { - ("chat", "completion") => Some(Scope::ReadOnly), - ("chat", "mint") => Some(Scope::Full), - _ => None, - } - } - } - - let policy = OnePolicy; - assert_eq!( - crate::required_scope(ClaimKey::new("chat"), OpClassId::new("completion"), &policy), - Some(Scope::ReadOnly) - ); - assert_eq!( - crate::required_scope(ClaimKey::new("chat"), OpClassId::new("mint"), &policy), - Some(Scope::Full) - ); - // The same operation class under a claim the policy does not mention is not the same question. - assert_eq!( - crate::required_scope( - ClaimKey::new("other"), - OpClassId::new("completion"), - &policy - ), - None, - "an operation nobody wrote a policy entry for has not been authorized" - ); -} diff --git a/crates/busbar-kernel-scope/src/tests/narrowing.rs b/crates/busbar-kernel-scope/src/tests/narrowing.rs deleted file mode 100644 index 366eb83b6a..0000000000 --- a/crates/busbar-kernel-scope/src/tests/narrowing.rs +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Narrowing is MONOTONE: applying a ceiling never authorizes anything the uncapped grants did not. -//! -//! A module ceiling (`max_admin_scope:`) is the operator's statement that whatever the role bindings -//! union to, this module's principals may not exceed it. That is only a ceiling if it can subtract -//! and never add. The existing cases check one instance of it — a `Full` grant capped to `ReadOnly` -//! collapses — which is the case where the answer is obvious. What is pinned here is the property -//! over the WHOLE lattice, so a ceiling operator that got an incomparable pair backwards, or that -//! defaulted to the top rung instead of the bottom, has nowhere to hide. -//! -//! Stated as three laws, each checked against every grant set and every ceiling the chain admits: -//! -//! 1. **Narrowing only removes.** Anything the capped grants authorize, the uncapped grants already -//! authorized. This is the security direction, and it is the one an escalation would break. -//! 2. **Narrowing is idempotent.** Applying the same ceiling twice is applying it once. A ceiling -//! that kept moving would make a principal's authority depend on how many times the config was -//! reloaded. -//! 3. **Narrowing is order-independent.** Two ceilings applied in either order give the same -//! answer, so a principal matching two ceilinged bindings has one authority rather than one per -//! evaluation order. - -use super::*; - -/// Every grant set over the two-rung chain: nothing, each scope alone, and both. -fn every_grants() -> Vec { - let mut out = vec![Grants::default()]; - for a in Scope::ALL { - out.push(Grants::of(a)); - for b in Scope::ALL { - out.push(Grants::of(a).with(b)); - } - } - out -} - -/// Law 1, the security direction: a ceiling can only take authority away. -#[test] -fn a_ceiling_never_authorizes_anything_the_uncapped_grants_did_not() { - for held in every_grants() { - for cap in Scope::ALL { - let capped = held.capped_by(cap); - for needed in Scope::ALL { - if capped.allows(needed) { - assert!( - held.allows(needed), - "{capped:?} (= {held:?} capped by {cap:?}) authorizes {needed:?}, \ - which the uncapped grants did not — a ceiling that ESCALATES" - ); - } - } - // And the ceiling is a ceiling: nothing survives it that the ceiling itself does not - // authorize, which is the whole of what an operator writing `max_admin_scope:` is - // asking for. - for needed in Scope::ALL { - if capped.allows(needed) { - assert!( - cap.allows(needed), - "{capped:?} authorizes {needed:?}, which the ceiling {cap:?} does not" - ); - } - } - } - } -} - -/// Law 2: applying a ceiling twice is applying it once. -#[test] -fn a_ceiling_applied_twice_is_the_ceiling_applied_once() { - for held in every_grants() { - for cap in Scope::ALL { - let once = held.capped_by(cap); - assert_eq!( - once.capped_by(cap), - once, - "{held:?} capped by {cap:?} is not stable under a second application" - ); - } - } -} - -/// Law 3: two ceilings commute, so a principal's authority does not depend on evaluation order. -#[test] -fn two_ceilings_give_the_same_answer_in_either_order() { - for held in every_grants() { - for a in Scope::ALL { - for b in Scope::ALL { - assert_eq!( - held.capped_by(a).capped_by(b), - held.capped_by(b).capped_by(a), - "{held:?} under ceilings {a:?} and {b:?} depends on the order they are applied" - ); - } - } - } -} - -/// The union direction, stated as the other half of the same property: unioning role bindings only -/// ever ADDS, and the union of two roles authorizes exactly what one or the other did. -/// -/// The two together are what make the fold over a principal's bindings well-defined: union up, then -/// meet down, and neither step can be reordered into an escalation. -#[test] -fn a_union_authorizes_exactly_what_one_or_the_other_role_authorized() { - for held in every_grants() { - for added in Scope::ALL { - let union = held.with(added); - for needed in Scope::ALL { - assert_eq!( - union.allows(needed), - held.allows(needed) || Grants::of(added).allows(needed), - "{held:?} unioned with {added:?} on {needed:?}" - ); - } - assert!( - union.contains(added), - "a union keeps the scope it added: {held:?} + {added:?}" - ); - for s in Scope::ALL { - if held.contains(s) { - assert!( - union.contains(s), - "a union keeps what was already held: {held:?} + {added:?} lost {s:?}" - ); - } - } - } - } -} diff --git a/crates/busbar-kernel/src/admin/gate.rs b/crates/busbar-kernel/src/admin/gate.rs index 2c2054d8dc..1f69361dcc 100644 --- a/crates/busbar-kernel/src/admin/gate.rs +++ b/crates/busbar-kernel/src/admin/gate.rs @@ -47,23 +47,12 @@ pub const PATH_PLUGINS_INSPECT: &str = "/plugins/inspect"; // equals the bar this one function ENFORCES (`busbar_kernel::admin_verbs` documents the invariant), // so they name it across the honest crate boundary. A pure `(method, path) → Scope` function with no // state to leak. +// +// ONE MATRIX: this is `busbar_kernel_scope::admin_required_scope`, the only copy, which the root's +// approve step calls too (Q128 kernel-scope: one home per fact). Two enforced copies agreed only +// until the next change to one of them. pub fn required_scope(method: &axum::http::Method, path: &str) -> busbar_contract::authz::Scope { - use axum::http::Method; - use busbar_contract::authz::Scope; - if method == Method::GET || method == Method::HEAD { - return Scope::ReadOnly; - } - // Match RELATIVE to the one true prefix so the matrix can never drift from the mount grammar. - // A path outside the prefix (impossible for a mounted admin route) fails closed to `full`. - let rel = path.strip_prefix(ADMIN_PREFIX).unwrap_or(path); - // `POST /config/validate` (and `POST /plugins/inspect`) are STATELESS DRY-RUNS — reads in POST - // clothing (the body is the config to lint / tarball to preview, far past URL length limits). A - // read-only CI token must be able to lint configs. - if rel == PATH_CONFIG_VALIDATE || rel == PATH_PLUGINS_INSPECT { - return Scope::ReadOnly; - } - // Every other mutation (and any non-read extension method) is full-only. - Scope::Full + busbar_kernel_scope::admin_required_scope(method.as_str(), path) } /// THE ANSWERS THE KERNEL GIVES ON THE NATIVE-API ROOT ITSELF, in the frozen v1 error envelope. diff --git a/crates/busbar-kernel/src/audit/tests/chain_tests.rs b/crates/busbar-kernel/src/audit/tests/chain_tests.rs index e02354acd3..002def4063 100644 --- a/crates/busbar-kernel/src/audit/tests/chain_tests.rs +++ b/crates/busbar-kernel/src/audit/tests/chain_tests.rs @@ -612,15 +612,25 @@ fn an_egress_refusal_is_audited_on_the_chain() { scope_kind: "queue_broker", value: "kafka-prod".to_string(), }; + // The audit row names the refused destination as `:` and the refused caller. + let EgressRefusal::NoGrant { + caller, + scope_kind, + value, + .. + } = &refusal + else { + unreachable!("built as NoGrant above") + }; let mut chain: Chain = Chain::new(); let records = vec![chain.append( "admin", AuditInput { ts: 1, action: "queue.egress".to_string(), - resource: refusal.audit_resource(), + resource: format!("{scope_kind}:{value}"), outcome: crate::audit_ring::OUTCOME_REJECTED.to_string(), - principal: refusal.caller().to_string(), + principal: caller.clone(), }, )]; assert_eq!(verify_chain(&records), Ok(())); diff --git a/crates/busbar/src/root/kernel.rs b/crates/busbar/src/root/kernel.rs index af8fc9f44c..b53c924ec4 100644 --- a/crates/busbar/src/root/kernel.rs +++ b/crates/busbar/src/root/kernel.rs @@ -1689,8 +1689,6 @@ pub struct ProductionUnits { /// no read can see a unit half-settled, and no reader can write, because what crosses the seam /// is a value and never the ledger. pub durability: Arc>, - /// What the scope unit reads at Approve. Silence is a refusal. - pub scope_policy: crate::root::policy::ScopePolicy, /// The admin plane's bindings: the seam an operation's body is reached through, and the table /// of admin units the loop is currently walking. /// @@ -1742,7 +1740,6 @@ impl ProductionUnits { auth_chain: AuthChain, durability: crate::root::durability::Durability, breaker_policy: crate::root::adapters::BreakerPolicy, - scope_policy: crate::root::policy::ScopePolicy, #[cfg(feature = "root-admin")] admin: crate::root::units_admin::AdminBinding, store: VerbStoreHandle, ) -> Self { @@ -1751,7 +1748,6 @@ impl ProductionUnits { auth_chain, Arc::new(Mutex::new(durability)), breaker_policy, - scope_policy, #[cfg(feature = "root-admin")] admin, store, @@ -1771,7 +1767,6 @@ impl ProductionUnits { auth_chain: AuthChain, durability: Arc>, breaker_policy: crate::root::adapters::BreakerPolicy, - scope_policy: crate::root::policy::ScopePolicy, #[cfg(feature = "root-admin")] admin: crate::root::units_admin::AdminBinding, store: VerbStoreHandle, ) -> Self { @@ -1787,7 +1782,6 @@ impl ProductionUnits { trust: Trust, arrival_door: AdmissionDoor, durability, - scope_policy, #[cfg(feature = "root-admin")] admin, store, @@ -1915,7 +1909,6 @@ impl ProductionUnits { AuthChain::new(Vec::new(), false), Arc::clone(&durability), crate::root::adapters::BreakerPolicy::new(), - crate::root::policy::ScopePolicy::new(), crate::root::units_admin::AdminBinding::new(dispatch, door), store, ); diff --git a/crates/busbar/src/root/policy.rs b/crates/busbar/src/root/policy.rs index cf4fc88fbc..0c41d57073 100644 --- a/crates/busbar/src/root/policy.rs +++ b/crates/busbar/src/root/policy.rs @@ -6,18 +6,8 @@ //! //! ## Why a default is the wrong answer here //! -//! Every value below has a perfectly sensible `Default` or a perfectly sensible empty case, and -//! none of them is safe to bind: a transport built from its own default ignores the operator's -//! limits, and an empty scope view authorizes everything. -//! -//! **The scope view.** Its natural empty case is a policy that says nothing about anything, and the -//! scope unit is explicit that a pair the policy is silent about has NO required scope, which is a -//! REFUSAL and not a pass. An operation nobody wrote a policy entry for has not been authorized. A -//! view that inverted that — answering "read-only is enough" for an unknown pair, or answering -//! `Some` where it meant "I do not know" — would authorize by omission, and every plane's operation -//! classes would open at once. So the type below cannot express the inversion: it holds declared -//! entries and answers `None` for everything else, and the only way to permit something is to have -//! said so. +//! The value below has a perfectly sensible `Default`, and it is not safe to bind: a transport +//! built from its own default ignores the operator's limits. //! //! ## What is not here //! @@ -26,12 +16,8 @@ //! first, and a veto after it wins regardless of what it returned. That ordering belongs to the //! step, not to the policy it reads, so it is not a field of anything in this file. -use std::collections::BTreeMap; - use busbar_contract::transport::TransportSettings; -use busbar_contract::{ClaimKey, OpClassId}; use busbar_kernel::config::limits::LimitsResolved; -use busbar_kernel_scope::{PolicyView, Scope}; /// The settings every linked transport is built from, taken off the deployment's resolved limits /// rather than from a `Default`. @@ -89,52 +75,6 @@ pub(crate) fn client_settings_under( } } -/// The scope unit's policy view, over what the deployment's policy actually declared. -/// -/// It holds entries and nothing else. There is no default arm, no catch-all and no "unknown means -/// read-only": a pair with no entry answers `None`, and the scope unit reads `None` as a refusal. -/// The type is shaped so that the dangerous answer cannot be given by accident — you cannot -/// construct one that permits something it was not told about. -#[derive(Debug, Default, Clone)] -pub struct ScopePolicy { - entries: BTreeMap<(&'static str, &'static str), Scope>, -} - -impl ScopePolicy { - /// A policy that permits nothing, because it has been told nothing. - #[must_use] - pub fn new() -> Self { - ScopePolicy::default() - } - - /// Declare the scope one claim's operation class requires. - #[must_use] - pub fn declaring(mut self, claim: ClaimKey, op: OpClassId, scope: Scope) -> Self { - self.entries.insert((claim.as_str(), op.as_str()), scope); - self - } - - /// How many pairs the policy speaks about. - #[must_use] - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Whether the policy speaks about nothing, and therefore permits nothing. - #[must_use] - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -impl PolicyView for ScopePolicy { - fn required_scope(&self, claim: ClaimKey, op: OpClassId) -> Option { - // `None` here is a refusal, not a pass, and this is the whole of the implementation for - // exactly that reason: there is nowhere for a fallback to be added by accident. - self.entries.get(&(claim.as_str(), op.as_str())).copied() - } -} - #[cfg(test)] #[path = "tests/policy.rs"] mod tests; diff --git a/crates/busbar/src/root/tests/kernel.rs b/crates/busbar/src/root/tests/kernel.rs index 464c61b359..9778dd85df 100644 --- a/crates/busbar/src/root/tests/kernel.rs +++ b/crates/busbar/src/root/tests/kernel.rs @@ -389,7 +389,6 @@ fn the_units_assemble_from_values_configuration_decided() { AuthChain::new(Vec::new(), false), durability, crate::root::adapters::BreakerPolicy::new(), - crate::root::policy::ScopePolicy::new(), #[cfg(feature = "root-admin")] crate::root::units_admin::AdminBinding::new( std::sync::Arc::new(crate::root::units_admin::RefusingDispatch), @@ -403,13 +402,11 @@ fn the_units_assemble_from_values_configuration_decided() { #[cfg(feature = "root-admin")] assert!(units.admin.units.is_empty()); - // The journal opened nothing, the ledger is dual-writing, and the scope policy permits - // nothing until it is told to. All three are the safe end of a choice that had an unsafe - // end, and all three are checkable here rather than at the first request. + // The journal opened nothing and the ledger is dual-writing. Both are the safe end of a choice + // that had an unsafe end, and both are checkable here rather than at the first request. let durability = units.durability.lock().expect("durability lock"); assert!(!durability.on_disk()); assert!(durability.ledger.is_dual_writing()); - assert!(units.scope_policy.is_empty()); } /// **The units carry no metering policy, and the root has no pool-expansion boot check** (items diff --git a/crates/busbar/src/root/tests/policy.rs b/crates/busbar/src/root/tests/policy.rs index 39fe26786c..0204eaef75 100644 --- a/crates/busbar/src/root/tests/policy.rs +++ b/crates/busbar/src/root/tests/policy.rs @@ -3,7 +3,6 @@ //! super::*` reaches the private items it always did. use super::*; -use busbar_kernel_scope::required_scope; /// **The hazard**, on the transport axis: a client built from the crate's own `Default` ignores /// what the operator wrote. A deployment that caps request bodies at 1 KiB gets a transport that @@ -93,68 +92,3 @@ fn an_unset_body_cap_resolves_to_the_transport_default() { "the transport's own default body cap is the 32 MiB this deployment has always had" ); } - -/// **Silence is a refusal.** The pair nobody wrote an entry for answers nothing, and the scope -/// unit reads nothing as "not authorized". A view that answered a scope here would open every -/// operation class the policy forgot to mention. -#[test] -fn a_pair_the_policy_is_silent_about_is_refused() { - let policy = ScopePolicy::new(); - assert!(policy.is_empty()); - assert_eq!( - required_scope( - ClaimKey::new("some-claim"), - OpClassId::new("some-op"), - &policy - ), - None - ); -} - -/// A declared pair answers what it was declared as, and nothing near it answers by association. -#[test] -fn only_the_declared_pair_answers() { - let claim = ClaimKey::new("claim-one"); - let other_claim = ClaimKey::new("claim-two"); - let op = OpClassId::new("op-read"); - let other_op = OpClassId::new("op-write"); - - let policy = ScopePolicy::new().declaring(claim, op, Scope::ReadOnly); - - assert_eq!(policy.len(), 1); - assert_eq!(required_scope(claim, op, &policy), Some(Scope::ReadOnly)); - // The same claim's other operation class, and the other claim's same operation class, - // are both silent. The lookup key is the PAIR, and neither half implies the other. - assert_eq!(required_scope(claim, other_op, &policy), None); - assert_eq!(required_scope(other_claim, op, &policy), None); -} - -/// Both rungs are expressible, and a mutation declared as full stays full. The two-rung chain -/// is strict: read-only does not satisfy full. -#[test] -fn both_rungs_are_declarable_and_the_chain_is_strict() { - let claim = ClaimKey::new("claim"); - let read = OpClassId::new("op-read"); - let write = OpClassId::new("op-write"); - - let policy = ScopePolicy::new() - .declaring(claim, read, Scope::ReadOnly) - .declaring(claim, write, Scope::Full); - - assert_eq!(required_scope(claim, read, &policy), Some(Scope::ReadOnly)); - assert_eq!(required_scope(claim, write, &policy), Some(Scope::Full)); -} - -/// A later declaration of the same pair replaces the earlier one rather than accumulating, so a -/// policy has one answer per pair and a reload cannot leave two. -#[test] -fn redeclaring_a_pair_replaces_it() { - let claim = ClaimKey::new("claim"); - let op = OpClassId::new("op"); - let policy = ScopePolicy::new() - .declaring(claim, op, Scope::ReadOnly) - .declaring(claim, op, Scope::Full); - - assert_eq!(policy.len(), 1); - assert_eq!(required_scope(claim, op, &policy), Some(Scope::Full)); -} From 7e59c8ddf112f358c3a458d1bb4582cca92a98d4 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:59:57 -0700 Subject: [PATCH 2/2] kind-isolation: lower busbar-kernel-scope x plane [[law0]] ceiling 6 -> 3; Q128 deletes the per-plane liveness switch's mcp_server/mcp_tool docs and its MCP-plane test, and the slack row was the law0 figure's 18 -> 19 --- qa/kind-isolation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 2d8cc0b8e7..679b34f9b5 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1893,8 +1893,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel-scope" axis = "plane" -count = "6" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance" +count = "3" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance. -3 for Q128 kernel-scope: the per-plane liveness switch's docs (`mcp_server`/`mcp_tool`) and its MCP-plane test are deleted" [[law0]] crate = "busbar-kernel-wal"