diff --git a/crates/busbar-core-admin/src/v1/service.rs b/crates/busbar-core-admin/src/v1/service.rs index 750734bad9..cff347da24 100644 --- a/crates/busbar-core-admin/src/v1/service.rs +++ b/crates/busbar-core-admin/src/v1/service.rs @@ -32,7 +32,7 @@ use crate::v1::contract::{ }; use busbar_kernel::config::named_map::NamedMapSection; use busbar_kernel::config::{ - DeployCfg, HookCfg, HookKind, HookStage, PluginsCfg, PromptAccess, ProviderDef, UserAccess, + DeployCfg, HookCfg, HookKind, HookStage, PromptAccess, ProviderDef, UserAccess, }; /// The KEY NAMES of one opaque `settings:` bag, sorted — the REDACTED projection EVERY admin read diff --git a/crates/busbar-core-admin/src/v1/service_operations.rs b/crates/busbar-core-admin/src/v1/service_operations.rs index d0fde9748d..ddd3a2f7c7 100644 --- a/crates/busbar-core-admin/src/v1/service_operations.rs +++ b/crates/busbar-core-admin/src/v1/service_operations.rs @@ -16,22 +16,19 @@ //! where the block now lives. use super::*; -use busbar_kernel::plugin_admission::sign::{TrustInput, TrustPolicy}; +use busbar_kernel::plugin_admission::sign::TrustPolicy; -/// `plugins.trust` resolved as boot resolves it (the loader's `TrustPolicy::from_config`), after -/// warning about any malformed anti-downgrade floor — every admin moment the policy resolves -/// (catalog, reload, upload, rollback) warns exactly as boot does. `binary_version` is carried for +/// `plugins.trust` resolved by THE ROOT'S ONE BUILDER — the one boot admits plugins under, carried +/// on the snapshot (`App::plugin_trust`) — after warning about any malformed anti-downgrade floor, +/// so every admin moment the policy resolves (catalog, install, inspect, rollback) reaches boot's +/// verdict and warns exactly as boot does. No builder lives here. `binary_version` is carried for /// diagnostics only. -pub(crate) fn trust_policy(cfg: &PluginsCfg, binary_version: &str) -> Result { - cfg.warn_invalid_floors(); - TrustPolicy::from_config(TrustInput { - publishers: &cfg.publisher_keys(), - allow_unsigned: cfg.trust.allow_unsigned, - allow_third_party: cfg.trust.allow_third_party, - min_versions: &cfg.min_versions, - first_party_floors: &cfg.first_party_floors, - binary_version, - }) +pub(crate) fn trust_policy(app: &App, binary_version: &str) -> Result { + app.plugins_cfg.warn_invalid_floors(); + let build = app.plugin_trust.ok_or_else(|| { + "no plugin trust policy is installed (the composition root's)".to_string() + })?; + build(&app.plugins_cfg, binary_version) } impl AdminService { @@ -610,7 +607,7 @@ impl AdminService { // `store.module` config concern (read via `GET /config`), not summarized per-row here, // the same posture the compiled-in hook rows take (`active: None`). let mut out = linked_store_rows(); - let Ok(policy) = trust_policy(&self.app.plugins_cfg, env!("CARGO_PKG_VERSION")) else { + let Ok(policy) = trust_policy(&self.app, env!("CARGO_PKG_VERSION")) else { return out; }; @@ -881,8 +878,8 @@ impl AdminService { // ── 1. filename sanity: a bare tarball filename ── let file = validate_plugin_filename(file)?; - let policy = trust_policy(&self.app.plugins_cfg, env!("CARGO_PKG_VERSION")) - .map_err(AdminError::Validation)?; + let policy = + trust_policy(&self.app, env!("CARGO_PKG_VERSION")).map_err(AdminError::Validation)?; // ── 2. STRUCTURAL: in-memory unpack + manifest completeness + integrity + abi ── let unpacked = busbar_kernel::plugin_admission::tarball::unpack(tarball) @@ -1057,8 +1054,8 @@ impl AdminService { ))); } - let policy = trust_policy(&self.app.plugins_cfg, env!("CARGO_PKG_VERSION")) - .map_err(AdminError::Validation)?; + let policy = + trust_policy(&self.app, env!("CARGO_PKG_VERSION")).map_err(AdminError::Validation)?; let unpacked = busbar_kernel::plugin_admission::tarball::unpack(tarball) .map_err(|e| AdminError::Validation(format!("invalid plugin tarball: {e}")))?; @@ -1205,8 +1202,8 @@ impl AdminService { // third-party plugin can also roll back. Anything the target does NOT satisfy (a broken // signature, an un-opted-in third party) still fails: a rollback authenticates the OPERATOR, // never the ARTIFACT. - let mut policy = trust_policy(&self.app.plugins_cfg, &manifest.version) - .map_err(AdminError::Validation)?; + let mut policy = + trust_policy(&self.app, &manifest.version).map_err(AdminError::Validation)?; policy .min_versions .insert(manifest.name.clone(), manifest.version.clone()); diff --git a/crates/busbar-core-admin/src/v1/tests/service_tests.rs b/crates/busbar-core-admin/src/v1/tests/service_tests.rs index f4062d8f8d..eee9161665 100644 --- a/crates/busbar-core-admin/src/v1/tests/service_tests.rs +++ b/crates/busbar-core-admin/src/v1/tests/service_tests.rs @@ -4347,26 +4347,32 @@ fn the_store_catalog_heads_with_the_linked_store_rows() { assert_eq!(head, want); } -/// EVERY ADMIN MOMENT THE TRUST POLICY RESOLVES WARNS AS BOOT DOES: the catalog, reload, upload and -/// rollback all resolve through the one `trust_policy` (none resolves it any other way), and it -/// warns about a malformed anti-downgrade floor before resolving. +/// EVERY ADMIN MOMENT THE TRUST POLICY RESOLVES WARNS AS BOOT DOES: the catalog, install, inspect +/// and rollback all resolve through the one `trust_policy` (none resolves it any other way), which +/// builds nothing itself (the root's builder is installed), and it warns about a malformed +/// anti-downgrade floor before resolving. #[test] fn every_admin_trust_resolution_warns_about_a_malformed_floor() { use busbar_kernel::diagnostics::CONFIG_ANTIDOWNGRADE_FLOOR_INVALID; use tracing_subscriber::layer::SubscriberExt as _; let src = include_str!("../service_operations.rs"); assert_eq!( - src.matches("trust_policy(&self.app.plugins_cfg").count(), + src.matches("trust_policy(&self.app, ").count(), 4, - "catalog, reload, upload and rollback resolve through trust_policy" + "catalog, install, inspect and rollback resolve through trust_policy" + ); + assert_eq!( + src.matches("TrustPolicy::from_config(").count(), + 0, + "the admin surface builds no trust policy of its own: it resolves through the root's" ); - assert_eq!(src.matches("TrustPolicy::from_config(").count(), 1); let mut cfg = busbar_kernel::config::PluginsCfg::default(); cfg.min_versions.insert("p".into(), "v2".into()); + let app = crate::new_test_app().plugins_cfg(cfg).build(); let cap = busbar_kernel::test_support::warn_capture::WarnCapture::default(); let subscriber = tracing_subscriber::registry().with(cap.clone()); tracing::subscriber::with_default(subscriber, || { - super::operations::trust_policy(&cfg, "1.6.0").expect("the policy resolves"); + super::operations::trust_policy(&app, "1.6.0").expect("the policy resolves"); }); assert!( cap.contains(&CONFIG_ANTIDOWNGRADE_FLOOR_INVALID.banner().to_string()), @@ -4374,3 +4380,26 @@ fn every_admin_trust_resolution_warns_about_a_malformed_floor() { cap.messages() ); } + +/// THE ADMIN SURFACE JUDGES UNDER THE SNAPSHOT'S BUILDER AND NO OTHER: an App handed a root builder +/// that refuses is refused by every admin trust moment with that builder's words, and an App with no +/// builder resolves nothing (the catalog lists the linked rows alone). +#[test] +fn every_admin_trust_moment_resolves_through_the_snapshots_builder() { + fn refusing_root( + _: &busbar_kernel::config::PluginsCfg, + _: &str, + ) -> Result { + Err("the root's refusal".into()) + } + let app = crate::new_test_app().plugin_trust(refusing_root).build(); + assert_eq!( + super::operations::trust_policy(&app, "1.6.0") + .err() + .as_deref(), + Some("the root's refusal") + ); + let mut none = (*app).clone(); + none.plugin_trust = None; + assert!(super::operations::trust_policy(&none, "1.6.0").is_err()); +} diff --git a/crates/busbar-kernel/src/appbuild.rs b/crates/busbar-kernel/src/appbuild.rs index 24e1c61613..aa53890862 100644 --- a/crates/busbar-kernel/src/appbuild.rs +++ b/crates/busbar-kernel/src/appbuild.rs @@ -2036,6 +2036,7 @@ pub fn build_app_from_config( cost, plugins_dir: std::path::PathBuf::from(&plugins_cfg.dir), plugins_cfg, + plugin_trust: crate::preflight::root_rows().trust_policy, // Where `auth.key_ttl` is finally READ: the self-serve mint's token lifetime. // Config-validate already proved this parses; the fallback keeps a bad value from panicking. self_key_ttl_secs: cfg diff --git a/crates/busbar-kernel/src/preflight.rs b/crates/busbar-kernel/src/preflight.rs index 81932514c2..39b75252bc 100644 --- a/crates/busbar-kernel/src/preflight.rs +++ b/crates/busbar-kernel/src/preflight.rs @@ -133,6 +133,10 @@ pub struct RootInstall { /// the process's one dispatcher (ARCHITECT ruling 2026-09-29, the opener seam). `None` = no /// export module resolves. pub export_axis: Option<&'static dyn busbar_contract::export_calls::ExportAxis>, + /// The root's ONE plugin trust builder — the one boot admits plugins under — carried onto every + /// [`crate::state::App`] so the operator surface judges a plugin exactly as boot does. `None` = + /// no trust resolves (the admin catalog lists the linked rows alone; an upload is refused). + pub trust_policy: Option, } /// THE ROOT'S HOOK AXIS over one plugin registry (each configuration's registry gets its own). @@ -145,6 +149,15 @@ pub type HookAxisBuild = &std::sync::Arc, ) -> Result, String>; +/// THE ROOT'S PLUGIN TRUST BUILDER: the `plugins:` block, and the binary version a floor reads, to +/// the policy a plugin is judged under (the root's `trust_policy`, a `test-harness` build's +/// first-party key included). No other builder judges a plugin. +/// +/// # Errors +/// A reserved or malformed publisher, or the root's own refusal. +pub type TrustPolicyBuild = + fn(&config::PluginsCfg, &str) -> Result; + /// A test build has no root: its store double ([`crate::test_support::store_double`]) and ranking /// double stand in for the root's entries, the in-crate secret double /// ([`crate::test_support::secrets`]) as the secret axis, and the test axis for the exports. @@ -157,6 +170,7 @@ const STAND_IN: RootInstall = RootInstall { store_axis: Some(crate::test_support::store_axis_stand_in), secret_axis: Some(&crate::test_support::SecretsStandIn), export_axis: Some(&crate::test_support::export_axis::STAND_IN), + trust_policy: Some(crate::test_support::trust_policy_stand_in), }; /// The hook doors a test build links in place of the root's (the stand-in hook axis, diff --git a/crates/busbar-kernel/src/state.rs b/crates/busbar-kernel/src/state.rs index e9f17565a6..a0bb493d39 100644 --- a/crates/busbar-kernel/src/state.rs +++ b/crates/busbar-kernel/src/state.rs @@ -446,6 +446,11 @@ pub struct App { /// catalog entry's trust verdict. Carried on the snapshot (not a global) so it is testable and /// survives swaps. pub plugins_cfg: crate::config::PluginsCfg, + /// The composition root's ONE plugin trust builder ([`crate::preflight::RootInstall`]'s + /// `trust_policy`, the one boot admits plugins under): every admin moment that judges a plugin + /// (catalog, install, inspect, rollback) resolves `plugins_cfg` through it and through nothing + /// else, so the operator surface reports the verdict boot reaches. `None` = no root installed one. + pub plugin_trust: Option, // The cross-protocol translation seam's global fallback max-output-tokens and effort→budget table // no longer live on `App`: they are plane-specific vocabulary and now ride that plane's own // per-generation runtime object, populated from the neutral `PlaneBuildInput` carrier `appbuild` diff --git a/crates/busbar-kernel/src/test_support/mod.rs b/crates/busbar-kernel/src/test_support/mod.rs index ab67742e0c..ff0312aede 100644 --- a/crates/busbar-kernel/src/test_support/mod.rs +++ b/crates/busbar-kernel/src/test_support/mod.rs @@ -914,6 +914,7 @@ pub struct TestApp { explicit_no_overlay: bool, plugins_dir: Option, plugins_cfg: Option, + plugin_trust: Option, hook_env: Option, disk_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, /// When `true`, build an App that booted with NO plugin routes at all — an EMPTY live table AND an @@ -1045,6 +1046,7 @@ impl TestApp { explicit_no_overlay: false, plugins_dir: None, plugins_cfg: None, + plugin_trust: None, hook_env: None, disk_paths: None, no_plugin_routes: false, @@ -1168,6 +1170,12 @@ impl TestApp { self.plugins_cfg = Some(cfg); self } + /// Judge plugins under `build` — a composition root's own trust builder — instead of the test + /// build's [`trust_policy_stand_in`]. + pub fn plugin_trust(mut self, build: crate::preflight::TrustPolicyBuild) -> Self { + self.plugin_trust = Some(build); + self + } /// Give the snapshot DISK TRUTH — the `config.yaml` / `providers.yaml` paths a rebuild reads. /// Without these the app is EPHEMERAL and every rebuild-from-disk path takes its no-disk branch, @@ -2061,6 +2069,7 @@ impl TestApp { .plugins_dir .unwrap_or_else(|| std::path::PathBuf::from("plugins")), plugins_cfg: self.plugins_cfg.unwrap_or_default(), + plugin_trust: Some(self.plugin_trust.unwrap_or(trust_policy_stand_in)), self_key_ttl_secs: crate::governance::mint_policy::DEFAULT_KEY_TTL_SECS, mint_policy: std::sync::Arc::new(self.mint_policy.unwrap_or_default()), request_id_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new( @@ -2465,6 +2474,16 @@ pub fn trust_policy( Ok(policy) } +/// [`trust_policy`] as a test build's root trust row ([`crate::preflight::TrustPolicyBuild`]): what +/// a [`TestApp`] judges plugins under unless the test hands it the root's own. `binary_version` is +/// carried for diagnostics only. +pub fn trust_policy_stand_in( + cfg: &crate::config::PluginsCfg, + _binary_version: &str, +) -> Result { + trust_policy(cfg) +} + /// A test build's store axis (it has no composition root): the loader's axis over a dispatcher of /// the test process's own, minting `op_id`s from the kernel's one allocator. pub fn store_axis_stand_in() -> std::sync::Arc { diff --git a/crates/busbar/src/root/boot.rs b/crates/busbar/src/root/boot.rs index dc5e616c42..8cca4c9af0 100644 --- a/crates/busbar/src/root/boot.rs +++ b/crates/busbar/src/root/boot.rs @@ -64,7 +64,9 @@ pub fn registry( /// THE `plugins:` BLOCK'S TRUST, resolved: the embedded first-party key, the configured /// publishers and opt-ins and the per-name floors (`min_versions`, the rollback pins), through the /// loader's `TrustPolicy::from_config`. `binary_version` is carried for diagnostics only. The -/// automatic first-party floor is the registry build's to arm. +/// automatic first-party floor is the registry build's to arm. THE ONE BUILDER: the root hands this +/// same function to the kernel (`RootInstall::trust_policy`, `root::linked::register_stores`), and +/// every snapshot carries it to the admin surface, which judges a plugin under nothing else. /// /// # Errors /// diff --git a/crates/busbar/src/root/linked.rs b/crates/busbar/src/root/linked.rs index 0a11788b11..46b8d67b16 100644 --- a/crates/busbar/src/root/linked.rs +++ b/crates/busbar/src/root/linked.rs @@ -355,6 +355,7 @@ pub fn register_stores(linked: &Linked) { store_axis: Some(store_axis), secret_axis: Some(secrets), export_axis: Some(&crate::root::exports::EXPORTS), + trust_policy: Some(crate::root::boot::trust_policy), }), Err(refusal) => { eprintln!("busbar: {refusal}"); diff --git a/crates/busbar/src/root/tests/boot.rs b/crates/busbar/src/root/tests/boot.rs index fe0cdd4bda..e32959d1de 100644 --- a/crates/busbar/src/root/tests/boot.rs +++ b/crates/busbar/src/root/tests/boot.rs @@ -556,6 +556,85 @@ fn a_test_harness_build_takes_its_first_party_key_from_the_harness_variable() { )); } +/// THE ADMIN CATALOG'S ROW for a plugin a harness key signed first-party, read over +/// `GET /api/v1/admin/plugins?type=store` on a snapshot carrying the root's trust builder +/// ([`trust_policy`], what `root::linked::register_stores` installs for every snapshot), with the +/// harness variable set to that key: the row's (`valid`, `trust`). The key is not the kernel test +/// build's stand-in release key, so only the root's harness seam can admit it. +async fn the_admin_catalog_row_for_a_harness_signed_plugin( + tag: &str, +) -> (serde_json::Value, serde_json::Value) { + use crate::root::loader::sign::SigningKey; + use tower::ServiceExt as _; + let test_key = SigningKey::from_bytes(&[0x6b; 32]); + let artifact = b"\x7fELF admin catalog under the root's trust"; + let manifest = first_party_signed_by(&test_key, artifact); + let dir = scratch(tag); + std::fs::write( + dir.join("harness-seam.tar.gz"), + crate::root::loader::tarball::package(&manifest, "lib.so", artifact).expect("it packs"), + ) + .expect("the tarball is written"); + let app = busbar_kernel::test_support::TestApp::new() + .admin_chain(vec![]) + .plugins_dir(dir.clone()) + .plugin_trust(trust_policy) + .build(); + let router = busbar_core_admin::build_router(app); + let request = axum::http::Request::builder() + .uri("/api/v1/admin/plugins?type=store") + .body(axum::body::Body::empty()) + .expect("a request"); + std::env::set_var( + HARNESS_KEY_VAR, + hex::encode(test_key.verifying_key().as_bytes()), + ); + let response = router.oneshot(request).await; + std::env::remove_var(HARNESS_KEY_VAR); + let response = response.expect("the admin surface answers"); + assert_eq!(response.status().as_u16(), 200); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body"); + let _ = std::fs::remove_dir_all(&dir); + let page: serde_json::Value = serde_json::from_slice(&bytes).expect("a JSON page"); + let row = page["items"] + .as_array() + // The row is found by its tarball's name, wherever the row states it. + .and_then(|rows| { + rows.iter() + .find(|r| r.to_string().contains("harness-seam.tar.gz")) + }) + .unwrap_or_else(|| panic!("the dropped plugin is listed: {page}")) + .clone(); + (row["valid"].clone(), row["trust"].clone()) +} + +/// **THE ADMIN CATALOG JUDGES UNDER BOOT'S TRUST (the fleet e2e that read it red).** In a +/// `test-harness` build with the harness key set, the catalog row for a plugin that key signed +/// first-party reads valid and trusted — the verdict boot admits it under — because the admin +/// surface resolves through the root's one builder, harness key included, and builds none of its +/// own. RED before: the admin crate built its own policy without the harness key and reported the +/// row rejected (`first-party plugin only`) while boot admitted the plugin. +#[cfg(feature = "test-harness")] +#[tokio::test] +async fn a_test_harness_admin_catalog_reads_a_harness_signed_plugin_as_valid() { + let (valid, trust) = + the_admin_catalog_row_for_a_harness_signed_plugin("admin-trust-green").await; + assert_eq!(valid, serde_json::json!(true), "trust: {trust}"); + assert_eq!(trust, serde_json::json!("trusted")); +} + +/// The red arm: in a build WITHOUT `test-harness`, the same plugin under the same variable is +/// refused by the admin catalog, as boot refuses it — the harness key reaches neither. +#[cfg(not(feature = "test-harness"))] +#[tokio::test] +async fn an_admin_catalog_without_test_harness_refuses_a_harness_signed_plugin() { + let (valid, trust) = the_admin_catalog_row_for_a_harness_signed_plugin("admin-trust-red").await; + assert_eq!(valid, serde_json::json!(false), "trust: {trust}"); + assert_eq!(trust, serde_json::json!("rejected")); +} + /// The features a `[features]` table turns on from `root`, transitively: only the package's own /// features (a `dep:` or `crate/feature` entry enables nothing of this package's own). fn features_reached(manifest: &str, root: &str) -> std::collections::BTreeSet {