From a096ddb26721e8f92b0ca5ad2d380f389916c204 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:01:58 -0800 Subject: [PATCH 01/14] Configure Mocktioneer endpoints through runtime policy --- README.md | 37 ++ auctioneer.toml | 2 + crates/auctioneer-bidder/src/bidder.rs | 26 ++ .../src/bidders/mocktioneer/bidder.toml | 3 + .../src/bidders/mocktioneer/config.rs | 353 ++++++++++++++++++ .../bidders/mocktioneer/fixtures/factory.json | 2 +- .../src/bidders/mocktioneer/mod.rs | 67 +++- .../src/bidders/mocktioneer/request.rs | 54 ++- .../src/endpoint_validator.rs | 50 +++ crates/auctioneer-bidder/src/lib.rs | 2 +- .../src/test_support/fake_bidder/mod.rs | 13 + crates/auctioneer-config/src/lib.rs | 2 + crates/auctioneer-config/src/url_safety.rs | 347 +++++++++++++++++ crates/auctioneer-exchange/src/run/mod.rs | 46 ++- .../src/validate_endpoint.rs | 93 ++++- .../tests/run_auction/main.rs | 108 +++++- ...2026-11-09-mocktioneer-runtime-endpoint.md | 90 +++++ ...-09-mocktioneer-runtime-endpoint-design.md | 138 +++++++ 18 files changed, 1404 insertions(+), 29 deletions(-) create mode 100644 crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs create mode 100644 docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md create mode 100644 docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md diff --git a/README.md b/README.md index 049a279..9eac367 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,43 @@ Run them from the workspace root. | Fastly Compute@Edge | `fastly compute serve` | | Spin | `spin up --from crates/auctioneer-adapter-spin` | +### Configure a Mocktioneer destination + +Mocktioneer is available only in builds with `insecure-bidders`. Its default +destination is `http://127.0.0.1:8787/openrtb2/auction`. To use another mock +deployment, add its endpoint to your runtime policy: + +```toml +[bidders.mocktioneer] +enabled = true + +[bidders.mocktioneer.settings] +implementation = "mocktioneer" + +[bidders.mocktioneer.settings.values] +endpoint = "https://mock-bidder.example.test/openrtb2/auction" +``` + +Use a literal absolute URL of at most 2048 UTF-8 bytes. Public destinations +require HTTPS; the development feature also allows HTTP to exact loopback +literals. Private/reserved addresses, localhost-alias services, credentials, +fragments and templates are rejected. Omitted/null settings inherit the base +bidder setting, then the compiled default; an empty endpoint is invalid. +Impression parameters cannot override the selected destination. + +Validate and push the complete policy to local Fastly/Viceroy from the workspace +root, then restart Viceroy to read the updated local config store: + +```sh +auctioneer-cli config validate --app-config auctioneer.fastly-local.toml --no-env +auctioneer-cli config push --adapter fastly --local --app-config auctioneer.fastly-local.toml --no-env +``` + +Use the same Mocktioneer-enabled feature set for the CLI and the serving guest. +Endpoint changes take effect through the existing config publication lifecycle; +they require no endpoint-specific rebuild. The other bidders' compiled endpoint +and host-allowlist rules remain unchanged. + ## Operational endpoints (`/_auctioneer/*`) `GET /_auctioneer/health/gvl` reports GVL archive health (archive age, diff --git a/auctioneer.toml b/auctioneer.toml index f13dc44..c9c6863 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -290,6 +290,7 @@ returnallbidstatus_default = false # credentials_secret: a secret-store key, never the credential bundle itself. # external_url: publisher-facing URL used by the owning protocol. # region: "us-east", "us-west", "eu" or "apac" for the regional implementation. +# endpoint: literal operator destination for development-only Mocktioneer. # BEGIN BIDDER EXAMPLE # [bidders.fake_bidder] # enabled = true @@ -309,6 +310,7 @@ returnallbidstatus_default = false # credentials_secret = "fake_credential_bundle" # external_url = "https://publisher.example" # region = "us-east" +# endpoint = "https://mock-bidder.example.test/openrtb2/auction" # END BIDDER EXAMPLE # Optional floor-only currency conversion. Append this block to opt in; response diff --git a/crates/auctioneer-bidder/src/bidder.rs b/crates/auctioneer-bidder/src/bidder.rs index 191d3d5..7b4b755 100644 --- a/crates/auctioneer-bidder/src/bidder.rs +++ b/crates/auctioneer-bidder/src/bidder.rs @@ -118,6 +118,22 @@ impl BidderError { /// Pure, synchronous bidder contract. No async, no I/O, no `EdgeZero` types. /// The exchange owns the network call between `make_requests` and `make_bids`. pub trait Bidder: Send + Sync { + /// Select an operator-configured endpoint from implementation-bound settings. + /// + /// The exchange captures the checked destination before construction. This + /// hook receives no request or header input; `None` retains the compiled + /// endpoint, macros and host allowlist. The result borrows its settings owner. + /// + /// # Errors + /// Returns a fixed bidder-local error when supplied settings are invalid. + fn configured_endpoint<'a>( + &'a self, + _requested: Option<&'a crate::BidderSettings>, + _base: Option<&'a crate::BidderSettings>, + ) -> Result>, BidderError> { + Ok(None) + } + /// Select a configured credential reference after pure parameter preflight. /// The exchange, not the bidder, loads its bounded atomic bundle. /// @@ -211,6 +227,16 @@ mod tests { }; struct PanickyBidder; + + #[test] + fn configured_endpoint_defaults_to_the_compiled_metadata_policy() { + assert!( + PanickyBidder + .configured_endpoint(None, None) + .unwrap() + .is_none() + ); + } #[expect( clippy::missing_trait_methods, reason = "this fixture must omit debug_body_schema to exercise its fail-closed default" diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml index e5905bf..dbe087a 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml @@ -2,6 +2,9 @@ key = "mocktioneer" factory = true required_feature = "insecure-bidders" +[settings.fields] +endpoint = "optional_string" + [endpoint.fields] account_id = "AccountID" publisher_id = "PublisherID" diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs new file mode 100644 index 0000000..2ff1000 --- /dev/null +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs @@ -0,0 +1,353 @@ +//! Borrowed deployment endpoint rules, shared with request-time selection. + +use crate::bidder_settings::mocktioneer::{Settings, resolve}; +use crate::{BidderConfigError, BidderConfigInput, BidderSettings}; +use validator::{Validate, ValidationError, ValidationErrors}; + +pub(super) struct Config<'a> { + requested: Option<&'a Settings>, + base: Option<&'a Settings>, +} + +impl<'a> From> for Config<'a> { + fn from(input: BidderConfigInput<'a>) -> Self { + Self { + requested: input.requested.and_then(BidderSettings::as_mocktioneer), + base: input.base.and_then(BidderSettings::as_mocktioneer), + } + } +} + +impl Validate for Config<'_> { + fn validate(&self) -> Result<(), ValidationErrors> { + endpoint(self.requested, self.base) + .map(|_endpoint| ()) + .map_err(|()| { + let mut errors = ValidationErrors::new(); + errors.add("endpoint", ValidationError::new("invalid")); + errors + }) + } +} + +/// Check both supplied entries before resolving requested/base fallback. +pub(super) fn endpoint<'a>( + requested: Option<&'a Settings>, + base: Option<&'a Settings>, +) -> Result, ()> { + for supplied in [requested, base].into_iter().flatten() { + if let Some(endpoint) = supplied.endpoint.as_deref() { + auctioneer_config::validate_configured_bidder_endpoint( + endpoint, + cfg!(feature = "insecure-bidders"), + ) + .map_err(|_reason| ())?; + } + } + Ok(resolve(requested, base).endpoint()) +} + +pub(super) fn error(_: ValidationErrors) -> BidderConfigError { + BidderConfigError { + field: "endpoint", + reason: "invalid", + } +} + +#[cfg(test)] +mod tests { + use super::super::{MocktioneerBidder, validate_config}; + use crate::bidder_settings::mocktioneer::{Settings, resolve}; + use crate::{Bidder as _, BidderConfigError, BidderConfigInput, BidderSettings}; + use auctioneer_config_data::types::BidderInfo; + use serde_json::{Value, json}; + use validator::Validate as _; + + fn settings(values: Value) -> BidderSettings { + let mut document = json!({"implementation":"mocktioneer"}); + document["values"] = values; + serde_json::from_value(document).unwrap() + } + + #[test] + fn omitted_and_null_endpoint_inherit_base_then_the_compiled_default() { + let base = settings(json!({"endpoint":"https://base.example.test:8443/base?route=one"})); + let explicit = + settings(json!({"endpoint":"https://requested.example.test:9443/requested?route=two"})); + let definition = BidderInfo::default(); + for values in [json!({}), json!({"endpoint":null})] { + let absent = settings(values); + assert!(absent.as_mocktioneer().unwrap().endpoint.is_none()); + for (requested, parent, expected) in [ + ( + Some(&absent), + Some(&base), + Some("https://base.example.test:8443/base?route=one"), + ), + ( + Some(&base), + Some(&absent), + Some("https://base.example.test:8443/base?route=one"), + ), + ( + Some(&explicit), + Some(&base), + Some("https://requested.example.test:9443/requested?route=two"), + ), + ( + Some(&base), + Some(&explicit), + Some("https://base.example.test:8443/base?route=one"), + ), + ( + None, + Some(&base), + Some("https://base.example.test:8443/base?route=one"), + ), + ( + Some(&base), + None, + Some("https://base.example.test:8443/base?route=one"), + ), + (Some(&absent), None, None), + (None, Some(&absent), None), + (Some(&absent), Some(&absent), None), + (None, None, None), + ] { + assert_eq!( + resolve( + requested.and_then(BidderSettings::as_mocktioneer), + parent.and_then(BidderSettings::as_mocktioneer) + ) + .endpoint(), + expected + ); + assert_eq!( + MocktioneerBidder + .configured_endpoint(requested, parent) + .unwrap() + .map(crate::ConfiguredEndpoint::as_str), + expected + ); + for enabled in [false, true] { + for candidate_definition in [None, Some(&definition)] { + assert_eq!( + validate_config(BidderConfigInput { + requested, + base: parent, + definition: candidate_definition, + enabled + }), + Ok(()) + ); + } + } + } + } + } + + #[test] + fn selected_https_literal_preserves_independent_path_query_port_and_borrow() { + for literal in [ + "https://mock-bidder.example.test", + "https://mock-bidder.example.test/openrtb2/auction", + "https://mock-bidder.example.test?route=one&format=json", + "https://mock-bidder.example.test:8443", + "https://mock-bidder.example.test:443", + "https://mock-bidder.example.test:8443/a%20b?route=a%2Fb", + ] { + let configured = settings(json!({"endpoint":literal})); + let stored = configured + .as_mocktioneer() + .unwrap() + .endpoint + .as_deref() + .unwrap(); + for (requested, base) in [(Some(&configured), None), (None, Some(&configured))] { + let selected = MocktioneerBidder + .configured_endpoint(requested, base) + .unwrap() + .unwrap() + .as_str(); + assert_eq!(selected, literal); + assert_eq!(selected.as_ptr(), stored.as_ptr()); + assert_eq!( + validate_config(BidderConfigInput { + requested, + base, + definition: None, + enabled: true + }), + Ok(()) + ); + } + } + } + + #[test] + fn exact_development_loopbacks_remain_admitted() { + for literal in [ + "http://127.0.0.1:8787/openrtb2/auction", + "http://[::1]:8787/openrtb2/auction?route=one", + "http://localhost:8787/openrtb2/auction", + "https://localhost:9443/openrtb2/auction", + ] { + let configured = settings(json!({"endpoint":literal})); + for (requested, base) in [(Some(&configured), None), (None, Some(&configured))] { + assert_eq!( + validate_config(BidderConfigInput { + requested, + base, + definition: None, + enabled: false + }), + Ok(()) + ); + assert_eq!( + MocktioneerBidder + .configured_endpoint(requested, base) + .unwrap() + .unwrap() + .as_str(), + literal + ); + } + } + } + + #[test] + fn supplied_invalid_endpoints_are_independently_rejected_disabled_or_shadowed() { + let good = settings(json!({"endpoint":"https://approved.example.test/auction"})); + let invalid = [ + "", + "http://public.example.test/private-canary", + "https://10.0.0.1/private-canary", + "https://172.16.0.1/private-canary", + "https://192.168.0.1/private-canary", + "https://169.254.0.1/private-canary", + "https://192.0.2.1/private-canary", + "https://[fc00::1]/private-canary", + "https://[::ffff:127.0.0.1]/private-canary", + "http://127.0.0.2:8787/private-canary", + "http://127.1:8787/private-canary", + "http://2130706433:8787/private-canary", + "http://0x7f000001:8787/private-canary", + "http://0177.0.0.1:8787/private-canary", + "http://[0:0:0:0:0:0:0:1]:8787/private-canary", + "http://localhost.:8787/private-canary", + "https://127.0.0.1.nip.io/private-canary", + "https://app.localtest.me/private-canary", + "https://sub.localhost/private-canary", + "https://@public.example.test/private-canary", + "https://private-canary:secret@public.example.test/auction", + "https://public.example.test/private-canary#", + "https://public.example.test/private-canary#fragment", + "https://{{.Host}}/private-canary", + "https://public.example.test/{{.Path}}/private-canary", + "https://public.example.test/private-canary?route={{.Route}}", + "https://public.example.test/private-canary?route=}}", + "public.example.test/private-canary", + "//public.example.test/private-canary", + "https:public.example.test/private-canary", + "https:/public.example.test/private-canary", + "https:///public.example.test/private-canary", + "https://", + "https://public.example.test/private-canary\\path", + " https://public.example.test/private-canary", + "https://public.example.test/private-canary ", + "https://public.example.test/private canary", + "https://public.example.test/private\ncanary", + "https://public.example.test/private\u{7f}canary", + "https://public.example.test/private\u{2003}canary", + "ftp://public.example.test/private-canary", + ]; + let definition = BidderInfo::default(); + let mut cases = 0_usize; + for literal in invalid { + let bad = settings(json!({"endpoint":literal})); + for enabled in [false, true] { + for candidate_definition in [None, Some(&definition)] { + for (requested, base) in [ + (Some(&bad), Some(&good)), + (Some(&good), Some(&bad)), + (Some(&bad), None), + (None, Some(&bad)), + ] { + let error = validate_config(BidderConfigInput { + requested, + base, + definition: candidate_definition, + enabled, + }) + .unwrap_err(); + assert_eq!( + error, + BidderConfigError { + field: "endpoint", + reason: "invalid" + } + ); + assert_eq!( + error.to_string(), + "bidder configuration invalid: endpoint (invalid)" + ); + assert!(!format!("{error:?} {error}").contains("private-canary")); + let refusal = MocktioneerBidder + .configured_endpoint(requested, base) + .err() + .unwrap(); + assert_eq!(refusal.code, 5); + assert_eq!(refusal.message, "invalid configured bidder endpoint"); + cases += 1; + } + } + } + } + assert_eq!(cases, 656); + } + + #[test] + fn programmatic_settings_enforce_utf8_byte_limit_and_sanitize_validator_errors() { + let at_limit = format!("https://bidder.example/{}a", "\u{e9}".repeat(1012)); + let over_limit = format!("https://bidder.example/{}aa", "\u{e9}".repeat(1012)); + assert_eq!(at_limit.len(), 2048); + assert_eq!(over_limit.len(), 2049); + let good = Settings { + endpoint: Some(at_limit), + }; + for (requested, base) in [(Some(&good), None), (None, Some(&good))] { + super::Config { requested, base }.validate().unwrap(); + } + for literal in [ + String::new(), + over_limit, + "https://private-canary:secret@public.example.test/auction".to_owned(), + ] { + let bad = Settings { + endpoint: Some(literal.clone()), + }; + for (requested, base) in [(Some(&bad), Some(&good)), (Some(&good), Some(&bad))] { + let errors = super::Config { requested, base }.validate().unwrap_err(); + assert!(!format!("{errors:?}").contains("private-canary")); + let fields = errors.field_errors(); + assert_eq!(fields.len(), 1); + assert_eq!(fields["endpoint"][0].code, "invalid"); + assert!(fields["endpoint"][0].params.is_empty()); + assert_eq!( + super::error(errors), + BidderConfigError { + field: "endpoint", + reason: "invalid" + } + ); + } + let direct = settings(json!({"endpoint":literal})); + let error = MocktioneerBidder + .configured_endpoint(Some(&direct), None) + .err() + .unwrap(); + assert_eq!(error.code, 5); + assert_eq!(error.message, "invalid configured bidder endpoint"); + } + } +} diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json index 90166e1..3cb1286 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json @@ -1,7 +1,7 @@ { "schema_version": 1, "implementation": "mocktioneer", - "manifest_sha256": "5714584cdf39c8c15cecb661bb3ed24a2f8138aa05256543a275da217a09cdb5", + "manifest_sha256": "309822d54f89a89272c1c56b45c7d6df6a0026aa3569dfb9060ab21e6fd97b74", "cases": [ { "name": "banner-and-unsent-sibling", diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs index 0e59fef..0d5f18b 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs @@ -5,12 +5,13 @@ //! builds. The relaxation lives behind `#[cfg(feature = //! "insecure-bidders")]`; in default builds this module does not exist. +mod config; mod request; mod response; use crate::debug_projection::DebugBodySchema; use crate::{ - OutboundRequestMeta, OutboundRequestSink, + BidderSettings, ConfiguredEndpoint, OutboundRequestMeta, OutboundRequestSink, bidder::{Bidder, BidderError}, request_info::BidderRequestInfo, types::{BidderHttpResponse, BidderResponse}, @@ -23,7 +24,30 @@ pub(crate) fn create_bidder() -> Box { Box::new(MocktioneerBidder) } +pub(super) fn validate_config( + input: crate::BidderConfigInput<'_>, +) -> Result<(), crate::BidderConfigError> { + use validator::Validate as _; + config::Config::from(input) + .validate() + .map_err(config::error) +} + impl Bidder for MocktioneerBidder { + fn configured_endpoint<'a>( + &'a self, + requested: Option<&'a BidderSettings>, + base: Option<&'a BidderSettings>, + ) -> Result>, BidderError> { + config::endpoint( + requested.and_then(BidderSettings::as_mocktioneer), + base.and_then(BidderSettings::as_mocktioneer), + ) + .map_err(|()| BidderError::failed_to_request_bids("invalid configured bidder endpoint"))? + .map(ConfiguredEndpoint::new) + .transpose() + } + fn debug_body_schema(&self) -> DebugBodySchema { DebugBodySchema::OpenRtb } @@ -50,6 +74,47 @@ impl Bidder for MocktioneerBidder { #[cfg(test)] mod tests { + #[test] + fn generated_endpoint_settings_accept_an_optional_string() { + let settings = serde_json::from_value::(serde_json::json!({ + "implementation": "mocktioneer", + "values": {"endpoint": "https://mock-bidder.example.test:8443/auction?route=one"} + })); + assert!( + settings.is_ok(), + "Mocktioneer must declare endpoint settings" + ); + } + + #[test] + fn endpoint_declaration_preserves_development_gate_and_compiled_loopback() { + let manifest: toml::Value = toml::from_str(include_str!("bidder.toml")).unwrap(); + assert_eq!( + manifest["settings"]["fields"]["endpoint"].as_str(), + Some("optional_string") + ); + assert_eq!( + manifest["required_feature"].as_str(), + Some("insecure-bidders") + ); + assert_eq!( + manifest["definitions"]["mocktioneer"]["endpoint"].as_str(), + Some("http://127.0.0.1:8787/openrtb2/auction") + ); + for values in [ + serde_json::json!({"unknown_endpoint":"https://example.test"}), + serde_json::json!({"endpoint":1_i32}), + serde_json::json!({"endpoint":false}), + serde_json::json!({"endpoint":[]}), + serde_json::json!({"endpoint":{}}), + ] { + let error = serde_json::from_value::( + serde_json::json!({"implementation":"mocktioneer", "values":values}), + ) + .unwrap_err(); + assert!(error.is_data()); + } + } #[test] fn declares_the_reviewed_debug_body_schema() { diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs index e3a3a75..8eab3d2 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs @@ -26,8 +26,60 @@ pub(super) fn make_requests( mod tests { use super::super::MocktioneerBidder; use crate::bidders::harness; - use crate::{BidderRequestInfo, bidder::EntryPoint, types::BidderName}; + use crate::{ + Bidder as _, BidderRequestInfo, BidderSettings, bidder::EntryPoint, types::BidderName, + }; use auctioneer_openrtb::types::bid_request::{BidRequest, Imp}; + use serde_json::json; + + #[test] + fn configured_endpoint_is_used_without_request_parameter_override_or_mutation() { + let request: BidRequest = serde_json::from_value(json!({ + "id":"configured-auction", + "imp":[{"id":"i-1", "banner":{"w":300_i32,"h":250_i32}, "ext":{"bidder":{ + "endpoint":"https://request-param.example.test:9443/untrusted?route=other", "bid":1.25_f64 + }}}] + })).unwrap(); + let before = serde_json::to_value(&request).unwrap(); + assert_eq!( + before["imp"][0]["ext"]["bidder"]["endpoint"], + "https://request-param.example.test:9443/untrusted?route=other" + ); + let settings: BidderSettings = serde_json::from_value(json!({ + "implementation":"mocktioneer", "values":{"endpoint":"https://operator.example.test:8443/openrtb2/auction?route=one"} + })).unwrap(); + for (requested, base) in [(Some(&settings), None), (None, Some(&settings))] { + let endpoint = MocktioneerBidder + .configured_endpoint(requested, base) + .unwrap() + .unwrap(); + let mut info = BidderRequestInfo::new( + BidderName::new("mock-alias"), + BidderName::new("mocktioneer"), + "acct-x", + None, + endpoint.as_str(), + &[], + None, + None, + EntryPoint::Auction, + &[], + ); + info.set_settings(Some("mocktioneer"), requested, base) + .unwrap(); + let out = harness::drive_ok(&MocktioneerBidder, &request, &info); + assert_eq!(out.len(), 1); + assert_eq!( + out[0].url(), + "https://operator.example.test:8443/openrtb2/auction?route=one" + ); + assert_eq!( + serde_json::from_slice::(out[0].body()).unwrap(), + before + ); + assert_eq!(serde_json::to_value(&request).unwrap(), before); + } + } #[test] fn make_requests_targets_127_0_0_1_template() { diff --git a/crates/auctioneer-bidder/src/endpoint_validator.rs b/crates/auctioneer-bidder/src/endpoint_validator.rs index b62dd6e..d99a648 100644 --- a/crates/auctioneer-bidder/src/endpoint_validator.rs +++ b/crates/auctioneer-bidder/src/endpoint_validator.rs @@ -8,6 +8,36 @@ use crate::request_info::BidderRequestInfo; +/// A literal operator endpoint admitted before request construction. +/// +/// Borrows the bounded configuration owner. The private field prevents a bidder +/// from widening the exchange's captured destination with an unchecked string. +#[derive(Clone, Copy)] +pub struct ConfiguredEndpoint<'a>(&'a str); + +impl<'a> ConfiguredEndpoint<'a> { + /// Admit one configured destination using this build's URL policy. + /// + /// # Errors + /// Returns a fixed code-5 error for malformed, oversized or unsafe URLs. + pub fn new(value: &'a str) -> Result { + auctioneer_config::validate_configured_bidder_endpoint( + value, + cfg!(feature = "insecure-bidders"), + ) + .map_err(|_reason| { + crate::BidderError::failed_to_request_bids("invalid configured bidder endpoint") + })?; + Ok(Self(value)) + } + + /// Borrow the admitted literal without normalization or another allocation. + #[must_use] + pub const fn as_str(self) -> &'a str { + self.0 + } +} + /// Why an expanded outbound URL was refused. /// /// `#[non_exhaustive]` so a new deployment policy can add a reason @@ -50,6 +80,26 @@ pub trait EndpointValidator { #[cfg(test)] mod tests { + #[test] + fn configured_endpoint_borrows_the_literal_and_sanitizes_rejections() { + use std::ptr; + + let literal = String::from("https://configured.example.test:8443/bid?route=one"); + let endpoint = super::ConfiguredEndpoint::new(&literal).unwrap(); + assert!(ptr::eq(endpoint.as_str().as_ptr(), literal.as_ptr())); + assert_eq!(endpoint.as_str(), literal); + for invalid in [ + "https://private-canary@configured.example.test/bid", + "https://127.0.0.2/private-canary", + "http://configured.example.test/private-canary", + ] { + let error = super::ConfiguredEndpoint::new(invalid).err().unwrap(); + assert_eq!(error.code, 5); + assert_eq!(error.message, "invalid configured bidder endpoint"); + assert!(!error.message.contains("private-canary")); + } + } + use super::{EndpointRejectReason, EndpointValidator}; use crate::bidder::EntryPoint; use crate::request_info::BidderRequestInfo; diff --git a/crates/auctioneer-bidder/src/lib.rs b/crates/auctioneer-bidder/src/lib.rs index 1a5804d..11cb4da 100644 --- a/crates/auctioneer-bidder/src/lib.rs +++ b/crates/auctioneer-bidder/src/lib.rs @@ -41,7 +41,7 @@ pub use diagnostics::{ ConstructionDiagnostic, ConstructionDiagnostics, MAX_CONSTRUCTION_DIAGNOSTICS, }; pub use endpoint_builder::{EndpointBuilder, EndpointBuilderError}; -pub use endpoint_validator::{EndpointRejectReason, EndpointValidator}; +pub use endpoint_validator::{ConfiguredEndpoint, EndpointRejectReason, EndpointValidator}; pub use finalize::{Compression, FinalizeError, TransportFinalize, finalize_for_transport}; pub use meta::{ MAX_BIDDER_HEADERS, META_BIDDER_REGION_BYTES, META_BYTES_CAPACITY, META_FINALIZE_RESERVE_BYTES, diff --git a/crates/auctioneer-bidder/src/test_support/fake_bidder/mod.rs b/crates/auctioneer-bidder/src/test_support/fake_bidder/mod.rs index 978a721..f6c9303 100644 --- a/crates/auctioneer-bidder/src/test_support/fake_bidder/mod.rs +++ b/crates/auctioneer-bidder/src/test_support/fake_bidder/mod.rs @@ -17,6 +17,8 @@ use auctioneer_openrtb::types::bid_request::BidRequest; use std::sync::atomic::{AtomicUsize, Ordering}; pub struct FakeBidder<'a> { + /// Optional operator destination used by shared endpoint-policy controls. + pub configured_endpoint: Option<&'a str>, pub credential_reference: Option<&'a str>, pub construction_observer: Option<&'a (dyn Fn(&BidderRequestInfo<'_>) + Send + Sync)>, pub request_observer: Option<&'a RequestObserver>, @@ -44,6 +46,7 @@ pub struct FakeBidder<'a> { impl Default for FakeBidder<'_> { fn default() -> Self { Self { + configured_endpoint: None, credential_reference: None, construction_observer: None, request_observer: None, @@ -83,6 +86,16 @@ impl FakeBidder<'_> { } impl Bidder for FakeBidder<'_> { + fn configured_endpoint<'a>( + &'a self, + _requested: Option<&'a crate::BidderSettings>, + _base: Option<&'a crate::BidderSettings>, + ) -> Result>, BidderError> { + self.configured_endpoint + .map(crate::ConfiguredEndpoint::new) + .transpose() + } + fn credential_key<'a>( &'a self, request: &BidRequest, diff --git a/crates/auctioneer-config/src/lib.rs b/crates/auctioneer-config/src/lib.rs index f467c67..0383630 100644 --- a/crates/auctioneer-config/src/lib.rs +++ b/crates/auctioneer-config/src/lib.rs @@ -68,4 +68,6 @@ pub mod url_safety; #[cfg(all(feature = "config-model", not(feature = "build-support")))] mod url_safety; #[cfg(feature = "config-model")] +pub use url_safety::validate_configured_bidder_endpoint; +#[cfg(feature = "config-model")] pub mod usersync; diff --git a/crates/auctioneer-config/src/url_safety.rs b/crates/auctioneer-config/src/url_safety.rs index 1278d96..38c2c75 100644 --- a/crates/auctioneer-config/src/url_safety.rs +++ b/crates/auctioneer-config/src/url_safety.rs @@ -178,6 +178,73 @@ pub fn validate_url_spelling(raw: &str, parsed: &url::Url) -> Result<(), String> Ok(()) } +/// Validate a literal operator-configured bidder endpoint of at most 2048 UTF-8 bytes. +/// +/// Public HTTPS destinations are accepted. With `allow_insecure`, HTTP and HTTPS +/// also accept exactly `127.0.0.1`, `[::1]`, or case-insensitive `localhost`. +/// +/// # Errors +/// Returns only static reason categories for malformed, templated, credentialed, +/// fragmented, or unsafe destinations; rejected input is never included. +pub fn validate_configured_bidder_endpoint( + raw: &str, + allow_insecure: bool, +) -> Result<(), &'static str> { + if raw.is_empty() || raw.len() > 2048 { + return Err("endpoint length is invalid"); + } + if raw.bytes().any(|byte| byte.is_ascii_control()) { + return Err("endpoint contains control characters"); + } + if raw.contains("{{") || raw.contains("}}") { + return Err("endpoint templates are forbidden"); + } + let parsed = url::Url::parse(raw).map_err(|_error| "endpoint URL is malformed")?; + validate_url_spelling(raw, &parsed).map_err(|_error| "endpoint URL spelling is invalid")?; + if parsed.host_str().is_none() { + return Err("endpoint authority is missing"); + } + let authority = raw + .split_once("://") + .map_or("", |(_, rest)| rest) + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if authority.contains('@') || !parsed.username().is_empty() || parsed.password().is_some() { + return Err("endpoint userinfo is forbidden"); + } + if parsed.fragment().is_some() { + return Err("endpoint fragments are forbidden"); + } + if is_localhost_alias_host(raw) { + return Err("endpoint localhost aliases are forbidden"); + } + let is_loopback = is_loopback_range(raw); + if is_loopback { + // URL parsing canonicalizes alternative IP spellings and percent-encoded + // DNS labels. The local exception also requires the literal raw host. + let raw_host = if authority.starts_with('[') { + authority.split_inclusive(']').next() + } else { + authority.split(':').next() + } + .unwrap_or_default(); + let exact_loopback = raw_host == "127.0.0.1" + || raw_host == "[::1]" + || raw_host.eq_ignore_ascii_case("localhost"); + if !allow_insecure || !exact_loopback { + return Err("endpoint loopback authority is forbidden"); + } + } + if !is_loopback && parsed.scheme() == "http" { + return Err("endpoint requires HTTPS"); + } + if endpoint_has_private_ip_authority(raw) { + return Err("endpoint private IP authority is forbidden"); + } + Ok(()) +} + /// Validate path/query placeholders against their owning implementation. /// `Host` remains an authority token, not a declared value binding. /// @@ -658,6 +725,286 @@ pub fn endpoint_host(endpoint: &str) -> Option { mod tests { use super::{is_exact_loopback_literal, is_localhost_alias_host, is_loopback_range}; + #[test] + fn configured_endpoint_accepts_public_https_variations() { + for endpoint in [ + "https://bidder.example", + "https://bidder.example/openrtb2/auction", + "https://bidder.example?site=one&format=json", + "https://bidder.example:8443", + "https://bidder.example:443", + "https://bidder.example/a%20b?redirect=a%2Fb", + "https://bidder.example/user@site?contact=a@b", + "HTTPS://BIDDER.EXAMPLE/openrtb2/auction?site=one", + "https://8.8.8.8/openrtb2/auction", + "https://[2606:4700:4700::1111]:8443/openrtb2/auction", + ] { + for allow_insecure in [false, true] { + assert_eq!( + crate::validate_configured_bidder_endpoint(endpoint, allow_insecure), + Ok(()), + "{endpoint}, allow_insecure={allow_insecure}" + ); + } + } + } + + #[test] + fn configured_endpoint_accepts_only_enabled_exact_loopbacks() { + for scheme in ["http", "https"] { + for host in ["127.0.0.1", "[::1]", "localhost", "LOCALHOST"] { + for port in ["", ":8787", ":443"] { + let endpoint = format!("{scheme}://{host}{port}/auction?site=one"); + assert_eq!( + crate::validate_configured_bidder_endpoint(&endpoint, true), + Ok(()), + "{endpoint}" + ); + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, false).is_err(), + "{endpoint}" + ); + } + } + } + } + + #[test] + fn configured_endpoint_rejects_public_http_in_both_modes() { + for allow_insecure in [false, true] { + assert!( + crate::validate_configured_bidder_endpoint( + "http://bidder.example/auction", + allow_insecure + ) + .is_err() + ); + } + } + + #[test] + fn configured_endpoint_rejects_normalized_and_alternate_loopbacks() { + for host in [ + "127.0.0.2", + "127.1", + "2130706433", + "0x7f000001", + "0177.0.0.1", + "127.0.0.1.", + "[0:0:0:0:0:0:0:1]", + "[0::1]", + "localhost.", + "LOCALHOST..", + "%6cocalhost", + "%31%32%37.0.0.1", + ] { + for scheme in ["http", "https"] { + for allow_insecure in [false, true] { + let endpoint = format!("{scheme}://{host}:8787/auction"); + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, allow_insecure) + .is_err(), + "{endpoint}, allow_insecure={allow_insecure}" + ); + } + } + } + } + + #[test] + fn configured_endpoint_rejects_private_reserved_and_mapped_ips() { + for host in [ + "10.0.0.1", + "172.16.0.1", + "192.168.0.1", + "100.64.0.1", + "169.254.0.1", + "0.0.0.0", + "192.0.2.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "[::]", + "[fc00::1]", + "[fe80::1]", + "[fec0::1]", + "[2001:db8::1]", + "[ff02::1]", + "[::ffff:127.0.0.1]", + "[::ffff:8.8.8.8]", + "[64:ff9b::808:808]", + ] { + for allow_insecure in [false, true] { + let endpoint = format!("https://{host}/auction"); + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, allow_insecure).is_err(), + "{endpoint}, allow_insecure={allow_insecure}" + ); + } + } + } + + #[test] + fn configured_endpoint_rejects_localhost_alias_services() { + for host in [ + "sub.localhost", + "nip.io", + "127.0.0.1.nip.io", + "app.xip.io", + "app.lvh.me", + "app.localtest.me", + "app.vcap.me", + "APP.LVH.ME.", + ] { + for allow_insecure in [false, true] { + let endpoint = format!("https://{host}/auction"); + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, allow_insecure).is_err(), + "{endpoint}, allow_insecure={allow_insecure}" + ); + } + } + } + + #[test] + fn configured_endpoint_rejects_missing_or_repaired_authority() { + for endpoint in [ + "", + "bidder.example/auction", + "//bidder.example/auction", + "https:bidder.example/auction", + "https:/bidder.example/auction", + "https:///bidder.example/auction", + "https:////bidder.example/auction", + "https://", + "https://?bidder.example", + "https://#bidder.example", + "https://[broken/auction", + "https://%zz/auction", + "ftp://bidder.example/auction", + r"https://bidder.example\auction", + r"https:\\bidder.example\auction", + ] { + for allow_insecure in [false, true] { + assert!( + crate::validate_configured_bidder_endpoint(endpoint, allow_insecure).is_err(), + "{endpoint:?}, allow_insecure={allow_insecure}" + ); + } + } + } + + #[test] + fn configured_endpoint_rejects_any_authority_userinfo_marker() { + for endpoint in [ + "https://@bidder.example/auction", + "https://:@bidder.example/auction", + "https://operator@bidder.example/auction", + "https://operator:secret@bidder.example/auction", + "https://operator%40name@bidder.example/auction", + "https://@@bidder.example/auction", + "http://@localhost:8787/auction", + ] { + for allow_insecure in [false, true] { + assert!( + crate::validate_configured_bidder_endpoint(endpoint, allow_insecure).is_err(), + "{endpoint}, allow_insecure={allow_insecure}" + ); + } + } + } + + #[test] + fn configured_endpoint_rejects_fragments_including_empty() { + for endpoint in [ + "https://bidder.example/auction#", + "https://bidder.example/auction#fragment", + "https://bidder.example?site=one#fragment", + ] { + assert!(crate::validate_configured_bidder_endpoint(endpoint, true).is_err()); + } + } + + #[test] + fn configured_endpoint_rejects_template_delimiters_anywhere() { + for endpoint in [ + "https://{{.Host}}/auction", + "https://bidder.example/{{.Path}}", + "https://bidder.example/auction?site={{.Site}}", + "https://bidder.example/auction?site={{", + "https://bidder.example/auction?site=}}", + ] { + assert!(crate::validate_configured_bidder_endpoint(endpoint, true).is_err()); + } + } + + #[test] + fn configured_endpoint_rejects_whitespace_and_ascii_controls() { + for whitespace in [' ', '\t', '\n', '\r', '\u{a0}', '\u{2003}'] { + for endpoint in [ + format!("{whitespace}https://bidder.example/auction"), + format!("https://bidder.example/auction{whitespace}"), + format!("https://bidder.example/auc{whitespace}tion"), + ] { + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, true).is_err(), + "{endpoint:?}" + ); + } + } + for control in (0_u8..=31).chain([127]) { + let endpoint = format!("https://bidder.example/auc{}tion", char::from(control)); + assert!( + crate::validate_configured_bidder_endpoint(&endpoint, true).is_err(), + "{endpoint:?}" + ); + } + } + + #[test] + fn configured_endpoint_enforces_independent_2048_and_2049_byte_boundaries() { + let at_limit = format!("https://bidder.example/{}", "a".repeat(2025)); + let over_limit = format!("https://bidder.example/{}", "a".repeat(2026)); + assert_eq!(at_limit.len(), 2048); + assert_eq!(over_limit.len(), 2049); + assert_eq!( + crate::validate_configured_bidder_endpoint(&at_limit, false), + Ok(()) + ); + assert!(crate::validate_configured_bidder_endpoint(&over_limit, false).is_err()); + } + + #[test] + fn configured_endpoint_length_limit_counts_utf8_bytes() { + let at_limit = format!("https://bidder.example/{}a", "\u{e9}".repeat(1012)); + let over_limit = format!("https://bidder.example/{}aa", "\u{e9}".repeat(1012)); + assert_eq!(at_limit.len(), 2048); + assert_eq!(over_limit.len(), 2049); + assert_eq!( + crate::validate_configured_bidder_endpoint(&at_limit, false), + Ok(()) + ); + assert!(crate::validate_configured_bidder_endpoint(&over_limit, false).is_err()); + } + + #[test] + fn configured_endpoint_errors_are_static_and_sanitized() { + for endpoint in [ + "https://secret-marker@bidder.example/auction", + "https://bidder.example/secret-marker#fragment", + "https://bidder.example/secret-marker/{{.Path}}", + "https://10.0.0.1/secret-marker", + "https:///bidder.example/secret-marker", + "http://bidder.example/secret-marker", + ] { + let error: &'static str = crate::validate_configured_bidder_endpoint(endpoint, true) + .expect_err("invalid endpoint must be rejected"); + assert!(!error.contains("secret-marker")); + assert!(!error.contains("bidder.example")); + assert!(!error.contains("10.0.0.1")); + } + } + #[test] fn loopback_dns_classification_ignores_root_dots() { for host in ["localhost", "LOCALHOST"] { diff --git a/crates/auctioneer-exchange/src/run/mod.rs b/crates/auctioneer-exchange/src/run/mod.rs index 29332ef..c30ef7c 100644 --- a/crates/auctioneer-exchange/src/run/mod.rs +++ b/crates/auctioneer-exchange/src/run/mod.rs @@ -42,9 +42,9 @@ use crate::{ use auctioneer_bidder::schema::{SchemaWorkBudget, native_schema_work_budget}; use auctioneer_bidder::{ Bidder, BidderHttpRequest, BidderName, BidderRequestInfo, BidderResponse, CallBudget, - ConstructionDiagnostics, DebugBodySchema, FinalizeError, OutboundBuildError, - OutboundRequestMeta, OutboundRequestSink, SinkLimits, SinkOutcome, TransportFinalize, TypedBid, - finalize_for_transport, + ConfiguredEndpoint, ConstructionDiagnostics, DebugBodySchema, FinalizeError, + OutboundBuildError, OutboundRequestMeta, OutboundRequestSink, SinkLimits, SinkOutcome, + TransportFinalize, TypedBid, finalize_for_transport, }; use auctioneer_config_data::types::{ BidderInfo, Compression as CfgCompression, IpKeepBits, MediaType, @@ -1017,11 +1017,12 @@ struct MakeRequestsOutput { schema_work: SchemaWorkBudget, } -fn bind_bidder_settings<'a>( +fn bind_bidder_configuration<'a>( ctx: &'a AuctionContext<'_>, pbr: &PerBidderRequest, + adapter: &'a dyn Bidder, info: &mut BidderRequestInfo<'a>, -) -> Result<(), auctioneer_bidder::BidderError> { +) -> Result>, auctioneer_bidder::BidderError> { let requested = ctx .bidder_settings .and_then(|source| source.settings(pbr.bidder.as_str())); @@ -1034,7 +1035,14 @@ fn bind_bidder_settings<'a>( .ok() .and_then(|index| ctx.bidder_bindings.get(index)) .map(|binding| binding.implementation); - info.set_settings(implementation, requested, base) + info.set_settings(implementation, requested, base)?; + let endpoint = adapter.configured_endpoint(info.settings(), info.base_settings())?; + if let Some(selected) = endpoint { + info.endpoint_template = selected.as_str(); + info.endpoint_macros = &[]; + info.host_allowlist = None; + } + Ok(endpoint) } async fn run_make_requests( @@ -1070,16 +1078,20 @@ async fn run_make_requests( ); info_for_adapter.original_request_ext_shape = ctx.original_request_ext_shape; info_for_adapter.floor_currency = ctx.floor_currency; - if let Err(error) = bind_bidder_settings(ctx, pbr, &mut info_for_adapter) { - out.bidder_pre_errors - .entry(pbr.bidder.clone()) - .or_default() - .push(ErrorEntry { - code: error.code, - message: error.message, - }); - return; - } + let configured_endpoint = + match bind_bidder_configuration(ctx, pbr, adapter, &mut info_for_adapter) { + Ok(endpoint) => endpoint, + Err(error) => { + out.bidder_pre_errors + .entry(pbr.bidder.clone()) + .or_default() + .push(ErrorEntry { + code: error.code, + message: error.message, + }); + return; + } + }; pbr.request.tmax = Some(i64::from(tmax.bidder_visible_tmax_ms)); apply_legacy_projection(&mut pbr.request, projection, info.openrtb.gpp_supported); if !schema::admit(ctx, pbr, tmax, out) { @@ -1109,7 +1121,7 @@ async fn run_make_requests( }; info_for_adapter.credentials = credentials.as_ref(); - let validator = ExchangeEndpointValidator::new(info); + let validator = ExchangeEndpointValidator::with_configured_endpoint(info, configured_endpoint); let mut sink = OutboundRequestSink::new( SinkLimits::new( usize::try_from(ctx.host.resource_limits.max_outbound_raw_bytes).unwrap_or(usize::MAX), diff --git a/crates/auctioneer-exchange/src/validate_endpoint.rs b/crates/auctioneer-exchange/src/validate_endpoint.rs index 3a328f1..5dd0a97 100644 --- a/crates/auctioneer-exchange/src/validate_endpoint.rs +++ b/crates/auctioneer-exchange/src/validate_endpoint.rs @@ -1,7 +1,7 @@ //! Exchange-owned outbound URL policy, shared by the sink and convenience API. use auctioneer_bidder::{ - BidderError, BidderRequestInfo, EndpointRejectReason, EndpointValidator, + BidderError, BidderRequestInfo, ConfiguredEndpoint, EndpointRejectReason, EndpointValidator, MAX_RETAINED_SINK_ERRORS, }; use auctioneer_config_data::types::BidderInfo; @@ -13,16 +13,22 @@ use std::net::{Ipv4Addr, Ipv6Addr}; /// # Errors /// Returns code 5 with fixed category text, never raw URL or parser details. pub fn validate_endpoint(url: &str, info: &BidderInfo) -> Result<(), BidderError> { + validate_endpoint_policy(url, &info.endpoint, info.host_allowlist.as_deref()) +} + +fn validate_endpoint_policy( + url: &str, + template: &str, + hosts: Option<&[String]>, +) -> Result<(), BidderError> { if url.contains("{{.") { return Err(public_error(EndpointRejectReason::TemplateUnresolved)); } let parsed = url::Url::parse(url) .map_err(|_parse_error| public_error(EndpointRejectReason::MalformedUrl))?; - let allow = info - .host_allowlist - .as_ref() - .map(|hosts| hosts.iter().map(String::as_str).collect::>()); - validate_parsed(&parsed, &info.endpoint, allow.as_deref()).map_err(public_error) + let allow = + hosts.map(|allowed_hosts| allowed_hosts.iter().map(String::as_str).collect::>()); + validate_parsed(&parsed, template, allow.as_deref()).map_err(public_error) } fn public_error(reason: EndpointRejectReason) -> BidderError { @@ -199,6 +205,7 @@ fn is_reserved_ipv6(v6: Ipv6Addr) -> bool { /// bounded category diagnostics remain available to direct callers. pub struct ExchangeEndpointValidator<'info> { info: &'info BidderInfo, + configured_endpoint: Option>, /// The message from each rejection, in rejection order, BOUNDED at /// [`MAX_RETAINED_SINK_ERRORS`]. /// @@ -211,8 +218,16 @@ impl<'info> ExchangeEndpointValidator<'info> { /// Open a validator over one bidder's configured surface. #[must_use] pub const fn new(info: &'info BidderInfo) -> Self { + Self::with_configured_endpoint(info, None) + } + + pub(crate) const fn with_configured_endpoint( + info: &'info BidderInfo, + configured_endpoint: Option>, + ) -> Self { Self { info, + configured_endpoint, rejections: RefCell::new(Vec::new()), } } @@ -235,7 +250,11 @@ impl EndpointValidator for ExchangeEndpointValidator<'_> { url: &url::Url, _request_info: &BidderRequestInfo<'_>, ) -> Result<(), EndpointRejectReason> { - match validate_endpoint(url.as_str(), self.info) { + let result = match self.configured_endpoint { + Some(endpoint) => validate_endpoint_policy(url.as_str(), endpoint.as_str(), None), + None => validate_endpoint(url.as_str(), self.info), + }; + match result { Ok(()) => Ok(()), Err(err) => { // BOUNDED, like the sink's own error list and for the @@ -281,6 +300,66 @@ mod tests { use auctioneer_bidder::{BidderName, BidderRequestInfo, EndpointValidator as _, EntryPoint}; use auctioneer_config_data::types::BidderInfo; + #[test] + fn configured_endpoint_captures_authority_independently_of_request_info() { + use auctioneer_bidder::ConfiguredEndpoint; + + let info = refusing_info(); + let selected = + ConfiguredEndpoint::new("https://configured.example.test:8443/auction?q=1").unwrap(); + let validator = ExchangeEndpointValidator::with_configured_endpoint(&info, Some(selected)); + let currency = Vec::new(); + let mut supplied = request_info(¤cy); + supplied.endpoint_template = "https://forged.example.test/auction"; + supplied.host_allowlist = Some(&["forged.example.test"]); + + let accepted = url::Url::parse("https://configured.example.test:8443/auction?q=1").unwrap(); + validator.validate(&accepted, &supplied).unwrap(); + for raw in [ + "https://forged.example.test:8443/auction", + "https://configured.example.test/auction", + "https://configured.example.test:8444/auction", + "http://configured.example.test:8443/auction", + "https://user@configured.example.test:8443/auction", + "https://127.0.0.2:8443/auction", + ] { + let rejected = url::Url::parse(raw).unwrap(); + assert!(validator.validate(&rejected, &supplied).is_err(), "{raw}"); + } + assert_eq!(validator.take_rejections().len(), 6); + } + + #[test] + fn configured_endpoint_rejects_before_body_construction_and_refunds_the_call() { + use auctioneer_bidder::{CallBudget, ConfiguredEndpoint, OutboundRequestSink, SinkLimits}; + use std::cell::Cell; + + let info = refusing_info(); + let selected = ConfiguredEndpoint::new("https://configured.example.test/auction").unwrap(); + let validator = ExchangeEndpointValidator::with_configured_endpoint(&info, Some(selected)); + let currency = Vec::new(); + let supplied = request_info(¤cy); + let budget = CallBudget::new(1); + let body_writes = Cell::new(0_usize); + let mut sink = OutboundRequestSink::new(SinkLimits::new(1024, 1), &budget, &validator); + let result = sink.push_request( + &supplied, + |meta| { + meta.set_method("POST")?; + meta.set_url("https://forged.example.test/auction")?; + meta.imp_id("i") + }, + |writer| { + body_writes.set(body_writes.get() + 1_usize); + writer.write_all(b"{}") + }, + ); + assert!(result.is_err()); + assert_eq!(body_writes.get(), 0_usize); + assert_eq!(budget.remaining(), 1); + assert!(sink.into_outcome().accepted.is_empty()); + } + /// A bidder whose configured surface rejects everything this test /// offers, so every `validate` call is a rejection. fn refusing_info() -> BidderInfo { diff --git a/crates/auctioneer-exchange/tests/run_auction/main.rs b/crates/auctioneer-exchange/tests/run_auction/main.rs index 3fdb7ba..3c0fe09 100644 --- a/crates/auctioneer-exchange/tests/run_auction/main.rs +++ b/crates/auctioneer-exchange/tests/run_auction/main.rs @@ -43,7 +43,7 @@ mod tests { use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; use std::time::Duration; @@ -125,6 +125,112 @@ mod tests { } } + #[derive(Default)] + struct EndpointFanout(Mutex>); + + #[async_trait(?Send)] + impl BidderFanout for EndpointFanout { + async fn send_all( + &self, + calls: Vec, + _deadline: AuctionDeadline, + ) -> Result, FanoutInternalError> { + self.0 + .lock() + .unwrap() + .extend(calls.iter().map(|call| call.request.url().to_owned())); + Ok(calls + .into_iter() + .map(|_| BidderCallResult { + result: Ok(BidderHttpResponse::new(204, vec![], vec![])), + elapsed: Duration::from_millis(1), + }) + .collect()) + } + } + + #[test] + fn configured_endpoint_reaches_the_real_auction_sink_without_request_overrides() { + const COMPILED: &str = "https://compiled.example.test/original"; + const CONFIGURED: &str = "https://configured.example.test:8443/auction?route=one"; + for selected in [ + None, + Some(CONFIGURED), + Some("http://unsafe.example.test/bid"), + ] { + let mut registry = BidderRegistry::new(); + registry + .register( + leak_info("fake_bb", COMPILED), + Box::new(FakeBidder { + configured_endpoint: selected, + ..FakeBidder::default() + }), + ) + .unwrap(); + let request = prepare( + serde_json::from_value(json!({ + "id": "endpoint-test", "site": {"page": "https://publisher.example"}, + "regs": {"gdpr": 0_u8}, + "imp": [{"id": "i", "banner": {"w": 300_i32, "h": 250_i32}, + "ext": {"bidder": {"fake_bb": { + "endpoint": "https://forged.example.test/bid" + }}}}] + })) + .unwrap(), + &RequestHeaders::default(), + &IMP_CAP_NOT_UNDER_TEST, + ) + .unwrap(); + let before = serde_json::to_value(&*request).unwrap(); + let host = host_with_caps(1000, 2000); + let config = AccountConfig::default(); + let account = ResolvedAccount { + id: "publisher".into(), + source: AccountSource::DefaultFallback, + config: &config, + }; + let fanout = EndpointFanout::default(); + let clock = FakeClock::new(time::OffsetDateTime::UNIX_EPOCH); + let rng = SeededRng::new(42); + let ctx = AuctionContext::with_request_start( + clock.now_monotonic(), + &host, + &account, + ®istry, + &fanout, + &clock, + &rng, + GvlStore::baked(), + Gcl::baked(), + clock.now(), + false, + ); + let plan = AuctionPlan::new( + ResolvedAliases::empty(), + BTreeSet::new(), + response_budgets(ctx.bidders, ctx.host), + BTreeMap::new(), + ); + let mut audit = DisclosureAudit::default(); + let response = block_on(run_auction(&request, &ctx, &plan, &mut audit)) + .unwrap() + .into_bid_response() + .unwrap(); + let seen = fanout.0.lock().unwrap(); + if selected == Some("http://unsafe.example.test/bid") { + assert!(seen.is_empty()); + assert_eq!( + response.ext.as_ref().unwrap()["errors"]["fake_bb"][0]["code"], + 5_u32 + ); + } else { + assert_eq!(seen.as_slice(), &[selected.unwrap_or(COMPILED)]); + } + assert_eq!(serde_json::to_value(&*request).unwrap(), before); + } + } + const RESPONSE_BIDDERS: &[&str] = &["fake_aaa", "fake_bb", "fake_ccc", "fake_bmmmmm"]; fn auction_with_bidder_response(body: &[u8]) -> BidResponse { diff --git a/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md new file mode 100644 index 0000000..280281c --- /dev/null +++ b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md @@ -0,0 +1,90 @@ +# Mocktioneer runtime endpoint implementation plan + +> Follow test-driven development and the repository's verification ownership. +> The user approved the runtime endpoint contract in the November 9 conversation. + +**Goal:** Select Mocktioneer's destination through typed runtime settings while +retaining its compiled default and enforcing the selected authority in the sink. + +**Architecture:** Add a narrow portable literal-URL validator, bidder-owned typed +settings/resolution and a defaulted borrowed endpoint hook. The exchange binds +one trusted selection to both request construction and outbound validation. + +**Contract:** [Design](../specs/2026-11-09-mocktioneer-runtime-endpoint-design.md). +Related to #124; no change to another bidder's endpoint authority. + +## 1. Portable URL validation + +Files: `crates/auctioneer-config/src/url_safety.rs` and `src/lib.rs`. + +Write inline failing controls for absolute spelling, public HTTPS with independent +path/query/port values, exact loopback under both feature-policy inputs, private +and alternate loopback literals, DNS aliases, userinfo, fragments, templates, +whitespace, controls, backslashes and the 2048-byte boundary. Reuse existing +private-address and literal-loopback helpers through a narrow exported validator. +Keep fixed error categories and serving portability. + +Run the owner module's inline tests and strict selected-target lint. Confirm +the new boundary tests execute and that the production API uses the tested helper. + +## 2. Bind trusted endpoint policy in the exchange + +Files: `crates/auctioneer-bidder/src/bidder.rs`, +`crates/auctioneer-exchange/src/run/mod.rs` and `src/validate_endpoint.rs`. + +Add failing inline controls for the checked endpoint value and hook's no-override +default, and a shared-fake integration in the existing `run_auction` test target. +Prove exact selected authority +acceptance and rejection of another hostname, scheme and port before body writes. +Prove a forged bidder request-info object cannot change the captured authority. + +Implement a defaulted borrowed hook receiving only requested/base settings. Keep +existing settings binding before selection, fixed failure mapping and the default +metadata policy. Bind selected literals to construction without macros/allowlists +and capture the same selection in the enforcing validator. Avoid metadata clones +and preserve existing public endpoint validation semantics. + +Run affected bidder units, exchange endpoint units/public contract tests and the +owner's request-construction allocation tests. Keep shared controls synthetic. + +## 3. Mocktioneer settings and request selection + +Files: `crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml`, +`mod.rs`, new `config.rs`, existing inline `request.rs` tests, and +`fixtures/factory.json` digest metadata only. + +Add failing owner-local controls for generated endpoint settings, requested/base +fallback, omitted/null/empty settings, independent invalid supplied values under +disabled/shadowed combinations, sanitized refusals and request-parameter override +attempts. Add the optional-string declaration and pure validator callback using +existing conventions. Override the borrowed endpoint hook for Mocktioneer and +retain the exact compiled loopback endpoint and feature gate. + +Update the fixture's manifest digest solely to bind the reviewed declaration. +Keep protocol fixture payloads unchanged. Run Mocktioneer unit/conformance tests, +manifest pins, feature-enabled source/metadata smoke checks and secure-default +publication controls. + +## 4. CLI and Fastly verification and documentation + +Files: `auctioneer.toml` (commented bidder-owned example), `README.md` (brief usage +and development feature scope), and this design/plan. + +Verify real typed CLI validate and local push dry-run with a fictional HTTPS +endpoint and the Mocktioneer-enabled feature graph. Exercise the same request +through local Viceroy when its runner/backend prerequisites are available. +Compile the actual Fastly WASIp1 guest with `insecure-bidders`; report compilation, +guest execution and hosted qualification separately. Do not contact a hosted +bidder or push live configuration as a verification substitute. + +Run formatting, affected native/guest lint and relevant source/layout/allocation +owners using serialized heavy Cargo jobs and retained caches. Record exact +commands, counts, failures and unavailable prerequisites in the PR description. + +## 5. Review and publication + +Request independent spec/implementation review, resolve material findings, then +commit only this branch's intended files. Open the user-requested focused PR +against `IABTechLab/auctioneer:main` with the repository template and actual +verification evidence. Preserve the original worktree's untracked local policy +and planning files. Do not merge or change issue/project status. diff --git a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md new file mode 100644 index 0000000..f052572 --- /dev/null +++ b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md @@ -0,0 +1,138 @@ +# Configure Mocktioneer endpoints through runtime policy + +Status: operator contract approved in the November 9 conversation; implementation +and target verification belong to the accompanying PR. Related to #124. + +## Problem and outcome + +Mocktioneer currently always sends to the compiled +`http://127.0.0.1:8787/openrtb2/auction` endpoint. Its parameter schema mentions an +endpoint field, but the Rust request owner deliberately ignores request-level +endpoint overrides. Operators need to select their Mocktioneer deployment using +the existing typed config validate/diff/push flow without rebuilding for each +host. Keep the compiled loopback default and its qualification fixtures intact. + +The approved configuration is: + +```toml +schema_version = 1 + +[bidders.mocktioneer.settings] +implementation = "mocktioneer" + +[bidders.mocktioneer.settings.values] +endpoint = "https://mock-bidder.example.test/openrtb2/auction" +``` + +Mocktioneer remains behind `insecure-bidders`, including when its selected +endpoint uses HTTPS. This is a development bidder feature, not a production +publication or qualification change. Existing deployments that omit settings +continue to use the exact compiled endpoint. + +## Configuration and authority + +Declare `endpoint = "optional_string"` in Mocktioneer's existing settings +manifest. Use the generated implementation-tagged settings types and their +fieldwise requested-identity/base fallback. Missing or null endpoint settings +inherit the base value, then the compiled default. An explicitly empty string +is invalid. Supplied invalid values must be rejected even when disabled or +shadowed by another identity's value. Unknown fields and mismatched +implementation tags retain their existing refusal behavior. + +The operator's authenticated config-store write is the authority for this +Mocktioneer-only destination. Request bodies, bidder parameters, account names, +headers, aliases and response redirects cannot authorize another endpoint. +Other bidder implementations keep their compiled endpoint and host-allowlist +contracts. No global endpoint-override configuration is introduced. + +## URL admission + +The endpoint is a literal absolute HTTP(S) URL, at most 2048 UTF-8 bytes. Reject +empty input, repaired/missing authority syntax, whitespace, ASCII controls, +backslashes, credentials/userinfo (including an empty userinfo marker), +fragments, and any template placeholder. Preserve a supplied path, query and +explicit port; they are operator configuration, not request substitutions. + +Reuse the shared URL-safety implementation. HTTPS public destinations are +accepted. HTTP is allowed only for the existing exact local literals +`127.0.0.1`, `[::1]` and `localhost` under `insecure-bidders`. Private, reserved +and alternate loopback literals and localhost-alias DNS services remain +rejected. This is URL admission, not a new DNS-resolution or rebinding defense; +provider/SDK transport guarantees retain their existing scope. + +Check the raw authority as well as the parsed host for the local exception: +shortened/integer/hexadecimal IPv4, expanded IPv6 loopback and root-dotted +localhost spellings do not become approved literals through URL normalization. + +All configuration and request-time refusal messages use fixed field/reason +categories. Never include a rejected URL, credentials, host or parser message. + +## Request construction and enforcing sink + +Add one defaulted, borrowed bidder endpoint-selection hook that accepts only +requested/base typed settings, with no request or header input. The default +returns no override. A private-field borrowed `ConfiguredEndpoint` value can +only be constructed through the shared literal-URL validator; the hook returns +that checked value rather than an unchecked string. Mocktioneer resolves its endpoint through +the same owner-local helper used by configuration validation. Direct callers +therefore cannot bypass URL admission by constructing settings programmatically. + +After existing settings binding, the exchange asks the trusted bidder for its +configured endpoint. One borrowed selection supplies both +`BidderRequestInfo.endpoint_template` and the enforcing exchange validator. +A selected literal URL has no macros or host allowlist: the sink requires its +exact scheme, hostname and port, and still rejects unsafe addresses. The +validator must capture this policy before bidder construction; a bidder cannot +substitute another request-info object to widen it. Without a selection, use +the existing metadata endpoint/macros/allowlist without a behavior change. + +The selection happens before credential lookup and request serialization. +Failures produce the existing fixed bidder error without a network call. +Retain reservation-before-body enforcement, bounded metadata/diagnostics, and +refund semantics. Mocktioneer continues to serialize the existing scoped +OpenRTB request and decode responses through the sealed shared decoder. + +## Ownership and resource effects + +Shared portable URL validation belongs to `auctioneer-config`; expose only the +narrow literal-endpoint validator needed by the bidder, without enabling native +build tooling in serving graphs. Mocktioneer owns its generated declaration, +semantic validator and endpoint resolution. The exchange owns binding the +trusted selection to construction and sink enforcement. Adapters do not parse +or reinterpret this policy. + +Borrow endpoint strings from the already bounded coherent operator snapshot. +Do not clone bidder metadata, allocate a second config tree, retain a new URL +cache, or add dependencies. URL parsing remains bounded by the endpoint limit. +Existing config byte/node/depth, metadata, call, body and deadline limits remain +unchanged. Adding a generated settings variant changes type/layout artifacts; +run the existing relevant layout/allocation owners and report any real change +instead of weakening frozen limits or substituting native evidence for guests. + +## Acceptance + +- Typed CLI validation and serving admission accept public HTTPS destinations + and existing exact-loopback development URLs, and refuse the independent + hostile URL boundaries described above with sanitized diagnostics. +- Alias/base fallback, omission/null, empty, disabled and shadowed settings are + exercised independently. No setting retains the original loopback behavior. +- A real exchange/sink test with the shared capable fake proves the same + selected policy reaches metadata construction and endpoint validation. A + mismatching host, scheme or port refuses before body construction; forged + request-info and request-level endpoint fields cannot override the policy. +- Mocktioneer-owned tests verify its generated settings, configured request URL, + original request immutability, and unchanged response behavior. +- Preserve the manifest-fixture pin by updating only its digest for the reviewed + settings declaration; retain all independent protocol requests and responses. +- Secure-default builds keep Mocktioneer absent. Feature-enabled metadata, + registry, config validation, relevant allocation/layout controls and Fastly + WASIp1 compilation exercise the new setting. Local Viceroy execution is + reported separately from hosted execution and production resource fit. + +## Delivery + +Use the existing CLI commands. `config push --adapter fastly --local` updates +the Viceroy manifest and requires restart. A hosted push uses the existing +service/store binding. No deployment, config push, secret provisioning or live +bidder traffic is authorized by opening this PR. Tests use fictional domains +and synthetic bidders; the operator's actual endpoint stays in local policy. From 5b68bf98c6f5e0e6ab826988a9ec3e2891b3357b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:12:31 -0800 Subject: [PATCH 02/14] Preserve auction construction layout for configured endpoints --- crates/auctioneer-exchange/src/run/mod.rs | 20 ++- .../src/validate_endpoint.rs | 168 ++++++++++++------ 2 files changed, 126 insertions(+), 62 deletions(-) diff --git a/crates/auctioneer-exchange/src/run/mod.rs b/crates/auctioneer-exchange/src/run/mod.rs index c30ef7c..cbd2885 100644 --- a/crates/auctioneer-exchange/src/run/mod.rs +++ b/crates/auctioneer-exchange/src/run/mod.rs @@ -37,14 +37,14 @@ use crate::{ parse_price_granularity, pick_winner, price_bucket, }, tmax::{TmaxResolution, resolve_tmax}, - validate_endpoint::ExchangeEndpointValidator, + validate_endpoint::BoundEndpointValidator, }; use auctioneer_bidder::schema::{SchemaWorkBudget, native_schema_work_budget}; use auctioneer_bidder::{ Bidder, BidderHttpRequest, BidderName, BidderRequestInfo, BidderResponse, CallBudget, - ConfiguredEndpoint, ConstructionDiagnostics, DebugBodySchema, FinalizeError, - OutboundBuildError, OutboundRequestMeta, OutboundRequestSink, SinkLimits, SinkOutcome, - TransportFinalize, TypedBid, finalize_for_transport, + ConstructionDiagnostics, DebugBodySchema, FinalizeError, OutboundBuildError, + OutboundRequestMeta, OutboundRequestSink, SinkLimits, SinkOutcome, TransportFinalize, TypedBid, + finalize_for_transport, }; use auctioneer_config_data::types::{ BidderInfo, Compression as CfgCompression, IpKeepBits, MediaType, @@ -1022,7 +1022,7 @@ fn bind_bidder_configuration<'a>( pbr: &PerBidderRequest, adapter: &'a dyn Bidder, info: &mut BidderRequestInfo<'a>, -) -> Result>, auctioneer_bidder::BidderError> { +) -> Result { let requested = ctx .bidder_settings .and_then(|source| source.settings(pbr.bidder.as_str())); @@ -1042,7 +1042,9 @@ fn bind_bidder_configuration<'a>( info.endpoint_macros = &[]; info.host_allowlist = None; } - Ok(endpoint) + // The selected URL already lives in `info`; carry only its policy choice + // across credential lookup instead of retaining a second URL borrow. + Ok(endpoint.is_some()) } async fn run_make_requests( @@ -1078,9 +1080,9 @@ async fn run_make_requests( ); info_for_adapter.original_request_ext_shape = ctx.original_request_ext_shape; info_for_adapter.floor_currency = ctx.floor_currency; - let configured_endpoint = + let has_configured_endpoint = match bind_bidder_configuration(ctx, pbr, adapter, &mut info_for_adapter) { - Ok(endpoint) => endpoint, + Ok(selected) => selected, Err(error) => { out.bidder_pre_errors .entry(pbr.bidder.clone()) @@ -1121,7 +1123,7 @@ async fn run_make_requests( }; info_for_adapter.credentials = credentials.as_ref(); - let validator = ExchangeEndpointValidator::with_configured_endpoint(info, configured_endpoint); + let validator = BoundEndpointValidator::new(info, &info_for_adapter, has_configured_endpoint); let mut sink = OutboundRequestSink::new( SinkLimits::new( usize::try_from(ctx.host.resource_limits.max_outbound_raw_bytes).unwrap_or(usize::MAX), diff --git a/crates/auctioneer-exchange/src/validate_endpoint.rs b/crates/auctioneer-exchange/src/validate_endpoint.rs index 5dd0a97..5850e76 100644 --- a/crates/auctioneer-exchange/src/validate_endpoint.rs +++ b/crates/auctioneer-exchange/src/validate_endpoint.rs @@ -1,7 +1,7 @@ //! Exchange-owned outbound URL policy, shared by the sink and convenience API. use auctioneer_bidder::{ - BidderError, BidderRequestInfo, ConfiguredEndpoint, EndpointRejectReason, EndpointValidator, + BidderError, BidderRequestInfo, EndpointRejectReason, EndpointValidator, MAX_RETAINED_SINK_ERRORS, }; use auctioneer_config_data::types::BidderInfo; @@ -205,7 +205,6 @@ fn is_reserved_ipv6(v6: Ipv6Addr) -> bool { /// bounded category diagnostics remain available to direct callers. pub struct ExchangeEndpointValidator<'info> { info: &'info BidderInfo, - configured_endpoint: Option>, /// The message from each rejection, in rejection order, BOUNDED at /// [`MAX_RETAINED_SINK_ERRORS`]. /// @@ -218,16 +217,8 @@ impl<'info> ExchangeEndpointValidator<'info> { /// Open a validator over one bidder's configured surface. #[must_use] pub const fn new(info: &'info BidderInfo) -> Self { - Self::with_configured_endpoint(info, None) - } - - pub(crate) const fn with_configured_endpoint( - info: &'info BidderInfo, - configured_endpoint: Option>, - ) -> Self { Self { info, - configured_endpoint, rejections: RefCell::new(Vec::new()), } } @@ -250,53 +241,84 @@ impl EndpointValidator for ExchangeEndpointValidator<'_> { url: &url::Url, _request_info: &BidderRequestInfo<'_>, ) -> Result<(), EndpointRejectReason> { - let result = match self.configured_endpoint { - Some(endpoint) => validate_endpoint_policy(url.as_str(), endpoint.as_str(), None), - None => validate_endpoint(url.as_str(), self.info), + record_validation_result(validate_endpoint(url.as_str(), self.info), &self.rejections) + } +} + +/// Borrow the exchange's immutable view after checked settings binding. +/// The bidder-supplied view passed to `validate` cannot widen this policy. +pub(crate) struct BoundEndpointValidator<'info> { + policy: BoundEndpointPolicy<'info>, + rejections: RefCell>, +} + +enum BoundEndpointPolicy<'info> { + Metadata(&'info BidderInfo), + Configured(&'info BidderRequestInfo<'info>), +} + +impl<'info> BoundEndpointValidator<'info> { + pub(crate) const fn new( + info: &'info BidderInfo, + bound: &'info BidderRequestInfo<'info>, + has_configured_endpoint: bool, + ) -> Self { + Self { + policy: if has_configured_endpoint { + BoundEndpointPolicy::Configured(bound) + } else { + BoundEndpointPolicy::Metadata(info) + }, + rejections: RefCell::new(Vec::new()), + } + } + + #[cfg(test)] + fn take_rejections(&self) -> Vec { + self.rejections.take() + } +} + +impl EndpointValidator for BoundEndpointValidator<'_> { + fn validate( + &self, + url: &url::Url, + _request_info: &BidderRequestInfo<'_>, + ) -> Result<(), EndpointRejectReason> { + let result = match self.policy { + BoundEndpointPolicy::Metadata(info) => validate_endpoint(url.as_str(), info), + BoundEndpointPolicy::Configured(info) => { + validate_parsed(url, info.endpoint_template, None).map_err(public_error) + } }; - match result { - Ok(()) => Ok(()), - Err(err) => { - // BOUNDED, like the sink's own error list and for the - // same reason. The sink keeps at most - // `MAX_RETAINED_SINK_ERRORS` typed errors and counts the - // rest; this kept every message a validator ever - // produced. A multi-attempt adapter that pushed a - // thousand rejected calls retained a thousand messages - // against the sink's sixteen, and the extra 984 were - // allocated, held for the whole construction, and then - // dropped unread — `run_make_requests` only ever draws - // one message per RETAINED sink error. - // - // Latent rather than live: every stock adapter pushes - // once per bidder. It is a construction-time bound all - // the same, and the one input that decides how many - // times `push_request` is called is adapter code. - // - // Keep-first, not keep-last, so position N on this side - // still describes position N on the sink's. Dropping the - // oldest would silently re-pair every message with a - // different rejection. - let mut rejections = self.rejections.borrow_mut(); - if rejections.len() < MAX_RETAINED_SINK_ERRORS { - rejections.push(err.message); - } - drop(rejections); - // The deployment policy this validator enforces is - // "the bidder's configured surface", and every check - // inside `validate_endpoint` is a facet of that one - // decision rather than of a distinguishable class the - // sink would act on differently. The concrete cause - // travels in the captured message. - Err(EndpointRejectReason::AuthorityMismatch) + record_validation_result(result, &self.rejections) + } +} + +fn record_validation_result( + result: Result<(), BidderError>, + rejections: &RefCell>, +) -> Result<(), EndpointRejectReason> { + match result { + Ok(()) => Ok(()), + Err(err) => { + // Match the sink's bounded, keep-first window so repeated attempts + // cannot retain unbounded diagnostics or change rejection order. + let mut retained = rejections.borrow_mut(); + if retained.len() < MAX_RETAINED_SINK_ERRORS { + retained.push(err.message); } + drop(retained); + // The sink acts on one deployment-policy rejection category; + // direct callers can inspect the fixed messages retained above. + Err(EndpointRejectReason::AuthorityMismatch) } } } #[cfg(test)] mod tests { - use super::{ExchangeEndpointValidator, MAX_RETAINED_SINK_ERRORS}; + use super::{BoundEndpointValidator, ExchangeEndpointValidator, MAX_RETAINED_SINK_ERRORS}; use auctioneer_bidder::{BidderName, BidderRequestInfo, EndpointValidator as _, EntryPoint}; use auctioneer_config_data::types::BidderInfo; @@ -304,11 +326,14 @@ mod tests { fn configured_endpoint_captures_authority_independently_of_request_info() { use auctioneer_bidder::ConfiguredEndpoint; - let info = refusing_info(); let selected = ConfiguredEndpoint::new("https://configured.example.test:8443/auction?q=1").unwrap(); - let validator = ExchangeEndpointValidator::with_configured_endpoint(&info, Some(selected)); let currency = Vec::new(); + let mut bound = request_info(¤cy); + bound.endpoint_template = selected.as_str(); + bound.host_allowlist = Some(&["configured.example.test", "forged.example.test"]); + let info = refusing_info(); + let validator = BoundEndpointValidator::new(&info, &bound, true); let mut supplied = request_info(¤cy); supplied.endpoint_template = "https://forged.example.test/auction"; supplied.host_allowlist = Some(&["forged.example.test"]); @@ -334,10 +359,12 @@ mod tests { use auctioneer_bidder::{CallBudget, ConfiguredEndpoint, OutboundRequestSink, SinkLimits}; use std::cell::Cell; - let info = refusing_info(); let selected = ConfiguredEndpoint::new("https://configured.example.test/auction").unwrap(); - let validator = ExchangeEndpointValidator::with_configured_endpoint(&info, Some(selected)); let currency = Vec::new(); + let mut bound = request_info(¤cy); + bound.endpoint_template = selected.as_str(); + let info = refusing_info(); + let validator = BoundEndpointValidator::new(&info, &bound, true); let supplied = request_info(¤cy); let budget = CallBudget::new(1); let body_writes = Cell::new(0_usize); @@ -360,6 +387,41 @@ mod tests { assert!(sink.into_outcome().accepted.is_empty()); } + #[test] + fn bound_metadata_keeps_the_declared_host_allowlist() { + let currency = Vec::new(); + let info = BidderInfo { + endpoint: "https://{{.Host}}/auction".into(), + host_allowlist: Some(vec!["east.example.test".into(), "west.example.test".into()]), + ..refusing_info() + }; + let bound = request_info(¤cy); + let validator = BoundEndpointValidator::new(&info, &bound, false); + let mut supplied = request_info(¤cy); + supplied.endpoint_template = "https://forged.example.test/auction"; + supplied.host_allowlist = Some(&["forged.example.test"]); + + for raw in [ + "https://east.example.test/auction", + "https://west.example.test/auction", + ] { + validator + .validate(&url::Url::parse(raw).unwrap(), &supplied) + .unwrap(); + } + for raw in [ + "https://forged.example.test/auction", + "http://east.example.test/auction", + ] { + assert!( + validator + .validate(&url::Url::parse(raw).unwrap(), &supplied) + .is_err() + ); + } + assert_eq!(validator.take_rejections().len(), 2); + } + /// A bidder whose configured surface rejects everything this test /// offers, so every `validate` call is a rejection. fn refusing_info() -> BidderInfo { From 74c20a7ac0430889016f4538c97c4bfe5f2cfaac Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:31:45 -0800 Subject: [PATCH 03/14] Define flat bidder settings contract --- .../plans/2026-11-09-flat-bidder-settings.md | 104 ++++++++++++ .../2026-11-09-flat-bidder-settings-design.md | 152 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-09-flat-bidder-settings.md create mode 100644 docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md diff --git a/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md new file mode 100644 index 0000000..1413e15 --- /dev/null +++ b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md @@ -0,0 +1,104 @@ +# Flat bidder settings implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Accept and emit bidder-owned fields directly under `.settings` for all implementations while retaining operator binding, validation and resource contracts. + +**Architecture:** One generated opaque settings object owns a bounded vector of closed scalar fields. Generated typed views borrow it; admission resolves the operator owner, and request projection pins that same owner independently of request aliases. The user-approved flat layout replaces the pre-release wrapped authoring format. + +**Tech Stack:** Existing Rust workspace, Serde, quote/syn code generation, existing config/owner/exchange test owners; no new dependencies. + +**Design:** `docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md`. + +## Task 1: Generate flat storage and borrowed owner views + +**Files:** +- Modify `crates/auctioneer-config/src/bidder_settings_codegen.rs`. +- Modify `crates/auctioneer-bidder/src/bidder_settings.rs` only if reusable scalar decoding belongs outside emitted code. +- Adapt settings consumers/tests under `crates/auctioneer-bidder/src/bidders/{msft,thetradedesk,rubicon,mocktioneer}/`. +- Adapt `crates/auctioneer-bidder/tests/bidder_settings_compile/{mod.rs,bidder_settings_semantics.rs}` and relevant compiler fixtures through quote/syn. + +- [ ] Add failing tests for flat JSON/TOML objects, canonical flat output, explicit null preservation, duplicate/unknown fields and independent scalar type/refusal boundaries. Confirm the old decoder rejects the requested flat shape. +- [ ] Generate a private owned field vector, closed key enum and scalar string/i64/null variants. Decode only objects, reject duplicates/coercions and retain explicit null keys. Stable output follows generated key order. Keep both scalar strings and keys bounded by existing settings/config admission. +- [ ] Generate these shared APIs (fixed diagnostics on failure): + +```rust +impl BidderSettings { + pub fn matches_implementation(&self, key: &str) -> bool; + pub fn matching_implementations(&self) -> impl Iterator + '_; + pub fn implementation_key(&self) -> Option<&'static str>; // pinned owner only + pub fn is_empty(&self) -> bool; // zero keys, not all-null + pub fn is_unbound_empty(&self) -> bool; + pub fn bind_implementation(&mut self, key: &'static str) -> Result<(), &'static str>; + pub fn as_mocktioneer(&self) -> Option>; + // Other owner accessors are discovered, not handwritten. +} +``` + +`matches_implementation` requires a declared schema, compatible supplied keys, +exact scalar kinds/secret reference rules and a compatible internal pin. A failed +rebind must leave an explicitly invalid object whose matches/accessors all refuse +and whose serialization fails; it must not silently clear or replace the old pin. +`is_unbound_empty` excludes this invalid state. Internal pins never serialize. + +- [ ] Generate each owner `Settings<'a>` as a `Clone + Copy` thin borrow with typed getters; string getters return `Option<&'a str>`, integer getters `Option`. Generate `resolve(requested: Option>, base: Option>) -> SettingsRef<'a>` with unchanged per-field fallback. No temporary references or string copies. +- [ ] Update owner-local consumers to call getters and store views by value. Preserve semantic validators and configured endpoint checks; update inline fixtures to flat fields without changing protocol captures or manifest provenance. +- [ ] Verify generator units and owner units/compile controls. Cover empty catalogs, integer-only/string owners, shared names with different scalar kinds, explicit null, and a newly declared synthetic owner. Keep fake ownership and exact dispatch assertions. + +## Task 2: Preserve admission and request-time implementation binding + +**Files:** +- Modify `crates/auctioneer-core/src/config/validation.rs`. +- Modify `crates/auctioneer-core/src/config/bidder_settings.rs`. +- Modify `crates/auctioneer-core/src/auction/prepare.rs`. +- Modify `crates/auctioneer-bidder/src/request_info.rs`. +- Extend `crates/auctioneer-bidder/tests/support/generated_settings_contract.rs` and its existing compiler harness in `tests/generated_settings.rs`. +- Adapt other existing fixtures only where the owner API/wire format changed. + +- [ ] Write synthetic regression cases for two owners sharing `token`, host aliases `x -> alpha` and `alpha -> beta`, settings on alpha, and request aliases retargeting a configured name. Confirm binding failures precede all callbacks, credentials and body closures, without failing valid siblings or mutating source input. +- [ ] Keep the first admission pass over the complete identity union. For each requested and fallback settings row, independently derive its operator owner from effective host aliases, compiled definitions, or a unique compatible linked settings-owning implementation for an unknown request-only identity. Compare that row owner against the context's expected owner before callbacks. +- [ ] Empty unknown request-only objects may remain unbound. Known settings-free owners reject any supplied settings, including `{}`. Unknown nonempty no-match or ambiguous schema candidates fail with the existing fixed binding category. Explicit null keys participate in matching and survive round trips. +- [ ] After the entire binding/schema pass succeeds, invoke existing semantic callbacks with borrowed typed views. Preserve intrinsic versus effective requiredness and disabled/shadowed validation. +- [ ] Extend projection with trusted operator identity inputs: + +```rust +pub fn project( + policy: &AuctioneerAppConfig, + requested: &[BidderName], + aliases: &AliasMap, + operator_aliases: &BTreeMap, + bindings: &[BidderImplementationBinding], +) -> Self; +``` + +The request alias map selects retained rows only. For each selected row derive +its pin from its independent operator context, never from a request alias target. +Resolve the effective host map before this call and pass `services.bidder_bindings`. +Only copies already required by existing projection ownership are permitted. +Retain a failed bind as a refusing/invalid settings object, never omit it. + +- [ ] Update `BidderRequestInfo::set_settings` to fallibly check both settings objects' owner pins, schema, kinds, references and limits before exposing either view. Clear previous bindings before any failure as currently required. Direct trusted callers can bind an unbound compatible object through the explicit implementation argument; an existing pin is immutable. +- [ ] Update generated contract fixtures through their ordinary owner modules and quote/syn wiring. Maintain independent expected values and workload sizes. Record actual executed dispatch counts; adjust reconciliation only for intentionally added test names, never to accept zero dispatch. + +## Task 3: Update the operator contract and verify the full affected boundary + +**Files:** +- Modify active examples in `README.md`, `auctioneer.toml`, `docs/bidders/controlled-native.md` and current deployment guidance if it contains the removed wrapper. +- Modify active CLI/config/integration fixtures that use tagged settings. +- Update the Mocktioneer design/plan with a dated superseding-format note and link; do not rewrite historical approvals, evidence or external captures. +- Preserve root-local operator configuration; update only the task-created local Mocktioneer config to the requested flat form when validation supports it. + +- [ ] Exercise the exact requested TOML shape and equivalent flat JSON for each existing implementation. Parse/push with the actual CLI; emitted config contains neither removed wrapper field. Check hostile Mocktioneer URLs and invalid shadowed/base values still refuse with redacted errors. +- [ ] Run focused generator and owner tests, both bidder/exchange feature lanes and public endpoint/settings contracts. Run the explicit generated-settings compiler owner, its allocation variants and config peak/lifecycle/SDK extraction controls from `docs/testing/development.md`. +- [ ] Run both Axum envelope lanes, both CLI `bidders_resources` lanes and exact native construction charge owners. No ceilings, caps, workload reductions, frozen total edits or unreviewed evidence recaptures. +- [ ] Run strict affected library/test Clippy, Fastly WASIp1 guest build/lint and actual guest settings/publication controls as applicable. Serialize heavy Cargo work and retain the existing target cache. +- [ ] Run format, source-import isolation, raw JSON fixture gate and diff checks. Read independent spec/implementation review findings and fix concrete issues. +- [ ] Commit and update the existing requested PR with an imperative title and description describing the final config behavior, reviewed design and actual verification. No deployment, merge or public review submission. Inspect the new CI run separately and report pending/blocked checks honestly. + +## Review and authorization + +The maintainer explicitly selected the flat `.settings` contract for every +bidder in the current task. Implementation choices preserve the existing +operator/request trust boundaries. The independent spec review's findings are +incorporated above; implementation and final review must check the concrete +alias, null, type and memory cases, not merely the example syntax. diff --git a/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md b/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md new file mode 100644 index 0000000..e1e75d5 --- /dev/null +++ b/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md @@ -0,0 +1,152 @@ +# Flat bidder settings design + +## Requested contract + +The maintainer requested the following operator format for every bidder: + +```toml +[bidders.mocktioneer] +enabled = true + +[bidders.mocktioneer.settings] +endpoint = "https://mock-bidder.example.test/openrtb2/auction" +``` + +Remove the operator-facing `implementation` discriminator and `values` wrapper +consistently from TOML, JSON, typed CLI push/load, SDK extraction, documentation +and current test inputs. Common bidder controls remain beside `settings`. +Fields, types and semantic validators remain bidder-owned. The same generated +path supports Microsoft, The Trade Desk, Rubicon, Mocktioneer and newly discovered +settings declarations without a handwritten implementation list. + +This extends #124 and the Mocktioneer runtime configuration work in PR #160. +Historical evidence and source captures retain their original bytes. This is a +pre-release authoring-format change within the current application schema; +existing tagged documents must be republished in the new form. Do not silently +maintain two accepted operator formats. + +## Constraints established by the current implementation + +`BidderOverrides` already provides the `.settings` boundary. The +wrapper is imposed by `bidder_settings_codegen`, not by the provider adapters. +The current internal tag also pins settings to an implementation. Removing it +must preserve that protection: request aliases can override host mappings and +even shadow a compiled public name. The endpoint/credentials selected for one +implementation must never be reinterpreted as another implementation's settings. + +Operator settings may name a future request-only alias. Its binding is unknown +at publication, so simply looking up the outer table name during deserialization +is insufficient. Empty settings, omission/null optional fields, independent +requested/base fallback and disabled/shadowed value validation must remain safe. + +## Alternatives + +1. **Flat generated scalar storage with borrowed typed views (recommended).** + Decode a bounded, redacted object, expose owner-specific borrowed fields, + resolve the implementation from the registry/host alias during admission, + and pin the same operator binding in the request-owned projection. This + supports shared field names across known implementations without guessing. +2. **Normalize flat input into the existing tagged enum.** This preserves owner + APIs but needs contextual staging at every parser boundary and a deferred + representation for request-only aliases. Avoid a second full config tree or + an allocation of cloned strings merely to recover the removed wire tag. +3. **Infer an enum variant from fields alone.** Simple for today's disjoint field + names, but ambiguous schemas and empty objects make this unsuitable as the + general bidder configuration contract. + +## Proposed data flow + +Generate an opaque flat `BidderSettings` object from discovered declarations. +Its one owned representation is a private `Vec` with a generated +closed key enum, string/i64/null scalar variants and an optional internal +`&'static str` implementation pin. Sort by generated key for stable output. +Serialization writes the fields directly as a map and omits the internal pin. +Only the existing closed scalar kinds are admitted: optional strings, signed +integers and secret references. Reject unknown keys, duplicate keys, arrays, +objects, booleans, floats, coercions and overflow, using fixed diagnostics. +Omitted and null optional values retain fieldwise absence; present empty strings +retain the owner's existing semantics. Keep the key of an explicit null during +schema checks and serialization: a null field must not become an absent field +and change which schemas match after a round trip. Empty means zero keys. +Redact all values in Debug/errors. + +Generate owner-specific `Settings<'a>` as thin `Copy` wrappers borrowing the +opaque object, with typed getter methods (`endpoint()`, `hb_source()`, etc.). +`as_owner()` returns `Option>`; `resolve` consumes two optional +Copy views and returns the existing fieldwise fallback view. No temporary typed +reference, `PhantomData`, per-owner string copy or dependency is needed. +Shared admission and request `set_settings` must first perform fallible checks +of both supplied entries' pins, allowed fields, scalar types, secret references +and limits. An accessor mismatch must never be treated as absent settings. + +For a compiled bidder or effective configured alias, admission resolves the +implementation through the existing independently compiled bindings. Validate +both supplied entries against that owner before invoking its semantic callback. +Independently resolve the operator owner of each supplied row, including a +fallback row. Host aliases do not chain: with `x -> alpha` and `alpha -> beta`, +the `alpha.settings` row is beta-owned and cannot supply alpha's settings to x, +even if both schemas declare the same fields. Finish the binding/schema pass +over the entire identity union before invoking any owner callback. +This permits two implementations to declare the same field name and still +select their own schema and rules. + +For a future request-only identity, nonempty settings must identify exactly one +compatible linked settings-owning implementation. Reject no-match and ambiguous +cases before activation; declaring the alias in operator configuration supplies +the missing identity in an ambiguous case. Empty settings carry no values and +may remain unbound until the requested identity is resolved. A known owner with +no settings declaration rejects supplied settings, including `{}`. Never select +the first of several matching schemas or take an implementation from request data. + +At request projection, independently recover the operator binding using the +effective host aliases and the host-supplied compiled bindings. Extend +`RuntimeBidderSettings::project` with those two trusted inputs; derive each row's +owner without consulting the request alias map (that map only selects which +rows to retain). Preserve an existing pin and refuse conflicting rebinding. +Store that +binding only in the existing request-owned settings object; omit it from wire +serialization. A request alias selecting a different implementation must fail +at settings binding before credentials, endpoint selection or body construction, +even when the two implementations share field names. Programmatic callers bind +an unbound object through their existing explicit trusted implementation input. + +## Resource and compatibility effects + +One bounded settings object owns scalar values; views borrow those values. +Projection makes only the copies already required by its existing lifetime +contract and does not copy unrelated settings or the whole policy. Charge and +measure any changed object/map layouts through their existing owners. Keep all +caps, ceilings, workloads and immutable evidence intact. The known construction +layout regression in PR #160 must not recur. + +Settings-free bidders, provider storage/envelopes, bidder parameters carried by +auction requests, privacy, alias precedence and credential ownership retain +their existing contracts. Configuration examples store literal URLs, not +Markdown link syntax. + +## Acceptance + +- The maintainer's exact table layout parses, serializes without the removed + wrappers, passes typed CLI validation/push dry-run and reaches serving code. +- Exercise all four existing owners with independent expected values; adding a + synthetic owner requires only its manifest and ordinary owner-local code. +- Keep typed rejection, absence/null/zero/empty semantics, per-field alias/base + fallback, credential redaction and validation of disabled/shadowed settings. +- Exercise compiled aliases, configured aliases, request-only aliases, empty + objects and ambiguous/shared field declarations with capable synthetic owners. +- Exercise cross-shadowed alias fallback, mixed null/shared-field round trips, + shared names with different scalar kinds and settings-free `{}` rejection. +- A request that retargets an operator-configured name cannot change its pinned + implementation, including shared field names; refusal precedes credentials + and body construction without mutating input or affecting valid siblings. +- Existing allocation/funding, exact native construction, CLI resource and guest + checks pass without changed limits or rewritten frozen evidence. +- Update active readers, writers and examples together; preserve historical + captures. Verify actual dispatched test counts and inspect CI independently. + +## Review + +An independent specification review identified the per-row fallback binding, +explicit-null identity, projection inputs and view-gating requirements above. +They are incorporated before implementation; no callback may observe an +unchecked row and no request alias may choose an operator settings pin. From 580ae9e46017c57559d6bf759af4487a1138b252 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:33:56 -0800 Subject: [PATCH 04/14] Separate bidder implementation selection from settings --- README.md | 10 +- auctioneer.toml | 18 +- .../src/bidders/mocktioneer/config.rs | 2 +- .../src/bidders/mocktioneer/mod.rs | 4 +- .../src/bidders/mocktioneer/request.rs | 2 +- .../src/bidders/msft/config.rs | 5 +- .../src/bidders/msft/request.rs | 6 +- .../msft/tests/allocation_conformance.rs | 6 +- .../src/bidders/msft/tests/conformance.rs | 2 +- .../tests/generated_settings_conformance.rs | 6 +- .../src/bidders/rubicon/config.rs | 2 +- .../src/bidders/rubicon/request.rs | 6 +- .../src/bidders/rubicon/test_support.rs | 2 +- .../rubicon/tests/allocation_conformance.rs | 2 +- .../src/bidders/rubicon/tests/conformance.rs | 2 +- .../rubicon/tests/response_conformance.rs | 2 +- .../src/bidders/thetradedesk/config.rs | 4 +- .../src/bidders/thetradedesk/request.rs | 2 +- .../tests/allocation_conformance.rs | 6 +- .../bidders/thetradedesk/tests/conformance.rs | 10 +- .../src/test_support/factory_contract.rs | 41 +++- .../bidder_settings_semantics.rs | 56 ++--- .../tests/bidder_settings_compile/mod.rs | 10 +- .../support/generated_exchange_settings.rs | 4 +- .../support/generated_settings_contract.rs | 160 ++++++------ crates/auctioneer-cli/src/application.rs | 44 ++-- .../src/bidder_settings_codegen.rs | 6 +- .../src/bidder_settings_wire.rs | 88 +++++++ crates/auctioneer-config/src/lib.rs | 2 + crates/auctioneer-config/src/source.rs | 96 +++++++- .../tests/bidder_settings_conformance.rs | 116 ++++++++- .../tests/native_bidder_onboarding/cases.rs | 17 +- .../native_bidder_onboarding/fixtures.rs | 25 +- docs/bidders/controlled-native.md | 24 +- .../plans/2026-11-09-flat-bidder-settings.md | 141 ++++------- ...2026-11-09-mocktioneer-runtime-endpoint.md | 4 + .../2026-11-09-flat-bidder-settings-design.md | 227 +++++++----------- ...-09-mocktioneer-runtime-endpoint-design.md | 5 + 38 files changed, 721 insertions(+), 444 deletions(-) create mode 100644 crates/auctioneer-config/src/bidder_settings_wire.rs diff --git a/README.md b/README.md index 9eac367..93c69eb 100644 --- a/README.md +++ b/README.md @@ -239,14 +239,18 @@ deployment, add its endpoint to your runtime policy: ```toml [bidders.mocktioneer] enabled = true - -[bidders.mocktioneer.settings] implementation = "mocktioneer" -[bidders.mocktioneer.settings.values] +[bidders.mocktioneer.settings] endpoint = "https://mock-bidder.example.test/openrtb2/auction" ``` +When settings are supplied, the bidder row requires `implementation`; the +separate `.settings` subconfig contains only that owner's fields. The key must +match the resolved bidder owner. Omit both fields to use inherited or compiled +defaults. Republish older wrapped configuration in this form after updating +the binary. + Use a literal absolute URL of at most 2048 UTF-8 bytes. Public destinations require HTTPS; the development feature also allows HTTP to exact loopback literals. Private/reserved addresses, localhost-alias services, credentials, diff --git a/auctioneer.toml b/auctioneer.toml index c9c6863..2988c91 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -278,13 +278,17 @@ returnallbidstatus_default = false # per field through the observable name, base, selected account and host, and # remains bounded by installed capacity. timeout_ms is 1..30000; sample_rate is # finite 0.0..1.0. enabled=false disables with a warning; sample_rate=0 silently -# excludes traffic. Settings do not register a new implementation or endpoint. +# excludes traffic. Settings do not register or activate a new implementation. # # This block is a FIELD REFERENCE, not a deployable combined settings object. -# Replace fake_bidder with a compiled public name, implementation with its owner, -# and keep ONLY values declared by that owner's bidder.toml [settings.fields]. -# The values table lists every currently supported bidder-owned field across all -# implementations. Do not paste that union into a single bidder's settings. +# Replace fake_bidder with a compiled public name, set implementation to its +# owner, and keep ONLY fields declared by that owner's bidder.toml +# [settings.fields]. The settings table lists every currently supported +# bidder-owned field across all implementations. Do not paste that union into a +# single bidder's settings. +# implementation: required owner key on the bidder row whenever settings are +# supplied. It must match the resolved bidder owner. The settings subconfig holds +# only that owner's fields. Omit both implementation and settings to use defaults. # hb_source/hb_source_video: integer protocol source values. # supply_id: nonempty routing identifier when required by the implementation. # credentials_secret: a secret-store key, never the credential bundle itself. @@ -294,6 +298,7 @@ returnallbidstatus_default = false # BEGIN BIDDER EXAMPLE # [bidders.fake_bidder] # enabled = true +# implementation = "fake_implementation" # usersync_enabled = false # timeout_ms = 300 # sample_rate = 1.0 @@ -301,9 +306,6 @@ returnallbidstatus_default = false # max_decoded_bytes = 262144 # # [bidders.fake_bidder.settings] -# implementation = "fake_implementation" -# -# [bidders.fake_bidder.settings.values] # hb_source = 5 # hb_source_video = 6 # supply_id = "fake_supply" diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs index 2ff1000..212fb4f 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs @@ -65,7 +65,7 @@ mod tests { fn settings(values: Value) -> BidderSettings { let mut document = json!({"implementation":"mocktioneer"}); - document["values"] = values; + document["settings"] = values; serde_json::from_value(document).unwrap() } diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs index 0d5f18b..fc3d0c2 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs @@ -78,7 +78,7 @@ mod tests { fn generated_endpoint_settings_accept_an_optional_string() { let settings = serde_json::from_value::(serde_json::json!({ "implementation": "mocktioneer", - "values": {"endpoint": "https://mock-bidder.example.test:8443/auction?route=one"} + "settings": {"endpoint": "https://mock-bidder.example.test:8443/auction?route=one"} })); assert!( settings.is_ok(), @@ -109,7 +109,7 @@ mod tests { serde_json::json!({"endpoint":{}}), ] { let error = serde_json::from_value::( - serde_json::json!({"implementation":"mocktioneer", "values":values}), + serde_json::json!({"implementation":"mocktioneer", "settings":values}), ) .unwrap_err(); assert!(error.is_data()); diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs index 8eab3d2..579ebe4 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/request.rs @@ -46,7 +46,7 @@ mod tests { "https://request-param.example.test:9443/untrusted?route=other" ); let settings: BidderSettings = serde_json::from_value(json!({ - "implementation":"mocktioneer", "values":{"endpoint":"https://operator.example.test:8443/openrtb2/auction?route=one"} + "implementation":"mocktioneer", "settings":{"endpoint":"https://operator.example.test:8443/openrtb2/auction?route=one"} })).unwrap(); for (requested, base) in [(Some(&settings), None), (None, Some(&settings))] { let endpoint = MocktioneerBidder diff --git a/crates/auctioneer-bidder/src/bidders/msft/config.rs b/crates/auctioneer-bidder/src/bidders/msft/config.rs index 7e59ca2..161e167 100644 --- a/crates/auctioneer-bidder/src/bidders/msft/config.rs +++ b/crates/auctioneer-bidder/src/bidders/msft/config.rs @@ -37,7 +37,7 @@ mod tests { #[test] fn typed_signed_sources_preserve_values_and_independent_field_fallback() { let definition = BidderInfo::default(); - let base: BidderSettings = serde_json::from_value(json!({"implementation":"msft", "values":{"hb_source":i64::MIN, "hb_source_video":i64::MAX}})).unwrap(); + let base: BidderSettings = serde_json::from_value(json!({"implementation":"msft", "settings":{"hb_source":i64::MIN, "hb_source_video":i64::MAX}})).unwrap(); for (values, expected) in [ (json!({}), (i64::MIN, i64::MAX)), ( @@ -48,7 +48,8 @@ mod tests { (json!({"hb_source_video":-7_i64}), (i64::MIN, -7)), ] { let requested: BidderSettings = - serde_json::from_value(json!({"implementation":"msft", "values":values})).unwrap(); + serde_json::from_value(json!({"implementation":"msft", "settings":values})) + .unwrap(); let resolved = msft::resolve(requested.as_msft(), base.as_msft()); assert_eq!( (resolved.hb_source(), resolved.hb_source_video()), diff --git a/crates/auctioneer-bidder/src/bidders/msft/request.rs b/crates/auctioneer-bidder/src/bidders/msft/request.rs index 46b6f83..3b71e02 100644 --- a/crates/auctioneer-bidder/src/bidders/msft/request.rs +++ b/crates/auctioneer-bidder/src/bidders/msft/request.rs @@ -792,10 +792,10 @@ mod tests { #[test] fn settings_precedence_is_resolved_before_zero_defaults_and_entrypoint_selection() { let requested: BidderSettings = serde_json::from_value(json!({"implementation":"msft", - "values":{"hb_source":0_i32,"hb_source_video":null}})) + "settings":{"hb_source":0_i32,"hb_source_video":null}})) .unwrap(); let base: BidderSettings = serde_json::from_value(json!({"implementation":"msft", - "values":{"hb_source":50_i32,"hb_source_video":60_i32}})) + "settings":{"hb_source":50_i32,"hb_source_video":60_i32}})) .unwrap(); let request = request(json!([banner("i", json!({"placement_id":1_i32}))])); // Exercise the generated borrowed view, not a bidder-local settings DTO. @@ -841,7 +841,7 @@ mod tests { for value in [i64::MIN, -1, 0, i64::MAX] { let settings: BidderSettings = serde_json::from_value(json!({"implementation":"msft", - "values":{(field):value}})) + "settings":{(field):value}})) .unwrap(); let mut info = info(entry); info.set_settings(Some("msft"), Some(&settings), None) diff --git a/crates/auctioneer-bidder/src/bidders/msft/tests/allocation_conformance.rs b/crates/auctioneer-bidder/src/bidders/msft/tests/allocation_conformance.rs index df0d2a1..b7b438e 100644 --- a/crates/auctioneer-bidder/src/bidders/msft/tests/allocation_conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/msft/tests/allocation_conformance.rs @@ -406,15 +406,15 @@ fn msft_chunk_construction_is_funded_across_payload_slots_caps_and_settings() { .unwrap() .factory)(); let base: BidderSettings = serde_json::from_value(json!({ - "implementation":"msft","values":{"hb_source":50_i64,"hb_source_video":60_i64}, + "implementation":"msft","settings":{"hb_source":50_i64,"hb_source_video":60_i64}, })) .unwrap(); let auction_zero: BidderSettings = serde_json::from_value(json!({ - "implementation":"msft","values":{"hb_source":0_i64}, + "implementation":"msft","settings":{"hb_source":0_i64}, })) .unwrap(); let video_zero: BidderSettings = serde_json::from_value(json!({ - "implementation":"msft","values":{"hb_source_video":0_i64}, + "implementation":"msft","settings":{"hb_source_video":0_i64}, })) .unwrap(); // Literal fieldwise/default oracles: an explicit zero blocks base diff --git a/crates/auctioneer-bidder/src/bidders/msft/tests/conformance.rs b/crates/auctioneer-bidder/src/bidders/msft/tests/conformance.rs index 87b3245..9b3839c 100644 --- a/crates/auctioneer-bidder/src/bidders/msft/tests/conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/msft/tests/conformance.rs @@ -392,7 +392,7 @@ mod tests { fn fixture_settings() -> BidderSettings { // Literal values from pinned TestJsonSamplesWithExtraInfo, not actual output. serde_json::from_value(json!({"implementation":"msft", - "values":{"hb_source":50,"hb_source_video":60}})) + "settings":{"hb_source":50,"hb_source_video":60}})) .unwrap() } diff --git a/crates/auctioneer-bidder/src/bidders/msft/tests/generated_settings_conformance.rs b/crates/auctioneer-bidder/src/bidders/msft/tests/generated_settings_conformance.rs index 4496d21..3010f63 100644 --- a/crates/auctioneer-bidder/src/bidders/msft/tests/generated_settings_conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/msft/tests/generated_settings_conformance.rs @@ -8,7 +8,7 @@ mod tests { #[test] fn generated_settings_keep_owned_fields_and_implementation_key() { let settings: BidderSettings = serde_json::from_value(json!({ - "implementation": "msft", "values": {} + "implementation": "msft", "settings": {} })) .unwrap(); assert_eq!(settings.implementation_key(), "msft"); @@ -18,7 +18,7 @@ mod tests { assert!(settings.as_thetradedesk().is_none()); let wire = json!({ - "implementation": "msft", "values": {"hb_source": 50_i32, "hb_source_video": 60_i32} + "implementation": "msft", "settings": {"hb_source": 50_i32, "hb_source_video": 60_i32} }); let populated: BidderSettings = serde_json::from_value(wire.clone()).unwrap(); let view = msft::resolve(populated.as_msft(), None); @@ -31,7 +31,7 @@ mod tests { fn settings_refuse_another_owners_field() { assert!( serde_json::from_value::(json!({ - "implementation": "msft", "values": {"supply_id": "x"} + "implementation": "msft", "settings": {"supply_id": "x"} })) .unwrap_err() .is_data() diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/config.rs b/crates/auctioneer-bidder/src/bidders/rubicon/config.rs index 07cb2e9..2885f07 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/config.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/config.rs @@ -90,7 +90,7 @@ mod tests { fn settings(values: Value) -> BidderSettings { let mut document = json!({"implementation":"rubicon"}); - document["values"] = values; + document["settings"] = values; serde_json::from_value(document).unwrap() } diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/request.rs b/crates/auctioneer-bidder/src/bidders/rubicon/request.rs index 24ddc36..4960f7a 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/request.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/request.rs @@ -514,7 +514,7 @@ mod tests { assert!(definitions["rubicon"].get("aliasOf").is_none()); for invalid in [json!(1_i32), json!(false), json!([]), json!({})] { let error = serde_json::from_value::(json!({ - "implementation":"rubicon","values":{"region":invalid} + "implementation":"rubicon","settings":{"region":invalid} })) .unwrap_err(); assert!(error.is_data()); @@ -563,7 +563,7 @@ mod tests { ("fake\u{85}reference".to_owned(), true), ] { let decoded = serde_json::from_value::(json!({ - "implementation":"rubicon", "values":{"credentials_secret":key,"region":"us-east"} + "implementation":"rubicon", "settings":{"credentials_secret":key,"region":"us-east"} })); if !valid { let error = decoded.unwrap_err(); @@ -618,7 +618,7 @@ mod tests { #[test] fn empty_requested_reference_is_rejected_before_base_fallback_or_settings_construction() { let decoded = serde_json::from_value::(json!({ - "implementation":"rubicon","values":{"credentials_secret":""} + "implementation":"rubicon","settings":{"credentials_secret":""} })); let error = decoded.unwrap_err(); assert!(error.is_data()); diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/test_support.rs b/crates/auctioneer-bidder/src/bidders/rubicon/test_support.rs index cadb1a8..ccae8b8 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/test_support.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/test_support.rs @@ -86,7 +86,7 @@ pub(super) fn context() -> BidderRequestInfo<'static> { } pub(super) fn settings(values: &Value) -> BidderSettings { - serde_json::from_value(json!({"implementation":"rubicon","values":values})).unwrap() + serde_json::from_value(json!({"implementation":"rubicon","settings":values})).unwrap() } pub(super) fn outbound(request: &BidRequest) -> Vec { diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/tests/allocation_conformance.rs b/crates/auctioneer-bidder/src/bidders/rubicon/tests/allocation_conformance.rs index 3e8d41c..765185b 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/tests/allocation_conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/tests/allocation_conformance.rs @@ -30,7 +30,7 @@ fn factory() -> fn() -> Box { } fn settings() -> BidderSettings { - serde_json::from_value(json!({"implementation":"rubicon","values":{ + serde_json::from_value(json!({"implementation":"rubicon","settings":{ "credentials_secret":"fake_credential_bundle","external_url":"http://hosturl.com", "region":"us-east" }})) diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/tests/conformance.rs b/crates/auctioneer-bidder/src/bidders/rubicon/tests/conformance.rs index 313b211..7ee5d16 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/tests/conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/tests/conformance.rs @@ -20,7 +20,7 @@ pub(super) fn with_source_context(run: impl FnOnce(&BidderRequestInfo<'_>) -> // All credential values and the external URL are TestJsonSamples literals, // not operator credentials or invented defaults. The reference is test-only. let settings: BidderSettings = serde_json::from_value(json!({ - "implementation":"rubicon", "values":{ + "implementation":"rubicon", "settings":{ "credentials_secret":"fake_credential_bundle", "external_url":"http://hosturl.com", "region":"us-east" } diff --git a/crates/auctioneer-bidder/src/bidders/rubicon/tests/response_conformance.rs b/crates/auctioneer-bidder/src/bidders/rubicon/tests/response_conformance.rs index 723e2f3..f7aea64 100644 --- a/crates/auctioneer-bidder/src/bidders/rubicon/tests/response_conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/rubicon/tests/response_conformance.rs @@ -40,7 +40,7 @@ fn context(bidder: &dyn Bidder) -> (BidRequest, BidderHttpRequest) { "ext":{"bidder":{"accountId":1001_i64,"siteId":113_932_i64,"zoneId":535_510_i64}} }]})).unwrap(); let settings: BidderSettings = - serde_json::from_value(json!({"implementation":"rubicon","values":{ + serde_json::from_value(json!({"implementation":"rubicon","settings":{ "credentials_secret":"fake_credential_bundle","external_url":"http://hosturl.com", "region":"us-east" }})) diff --git a/crates/auctioneer-bidder/src/bidders/thetradedesk/config.rs b/crates/auctioneer-bidder/src/bidders/thetradedesk/config.rs index c8f817b..f9fb80e 100644 --- a/crates/auctioneer-bidder/src/bidders/thetradedesk/config.rs +++ b/crates/auctioneer-bidder/src/bidders/thetradedesk/config.rs @@ -39,7 +39,7 @@ mod tests { fn typed_supply_preserves_absence_empty_and_host_fallback() { let definition = BidderInfo::default(); let parent: BidderSettings = serde_json::from_value(json!({ - "implementation":"thetradedesk", "values":{"supply_id":"fake_parent"} + "implementation":"thetradedesk", "settings":{"supply_id":"fake_parent"} })) .unwrap(); for (values, expected) in [ @@ -49,7 +49,7 @@ mod tests { (json!({"supply_id":"fake_supply"}), "fake_supply"), ] { let supplied: BidderSettings = - serde_json::from_value(json!({"implementation":"thetradedesk", "values":values})) + serde_json::from_value(json!({"implementation":"thetradedesk", "settings":values})) .unwrap(); assert_eq!( thetradedesk::resolve(supplied.as_thetradedesk(), parent.as_thetradedesk()) diff --git a/crates/auctioneer-bidder/src/bidders/thetradedesk/request.rs b/crates/auctioneer-bidder/src/bidders/thetradedesk/request.rs index 48d4f46..53e1e2d 100644 --- a/crates/auctioneer-bidder/src/bidders/thetradedesk/request.rs +++ b/crates/auctioneer-bidder/src/bidders/thetradedesk/request.rs @@ -470,7 +470,7 @@ mod tests { fn settings(supply: Option<&str>) -> BidderSettings { serde_json::from_value(json!({ - "implementation":"thetradedesk", "values":{"supply_id":supply}, + "implementation":"thetradedesk", "settings":{"supply_id":supply}, })) .unwrap() } diff --git a/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/allocation_conformance.rs b/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/allocation_conformance.rs index b61468b..a8c89a7 100644 --- a/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/allocation_conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/allocation_conformance.rs @@ -17,11 +17,11 @@ fn typed_settings_binding_and_field_resolution_allocate_nothing() { for width in [1, 64, 4_096] { thread::spawn(move || { let base: BidderSettings = serde_json::from_value(json!({ - "implementation":"thetradedesk", "values":{"supply_id":"x".repeat(width)} + "implementation":"thetradedesk", "settings":{"supply_id":"x".repeat(width)} })) .unwrap(); let alias: BidderSettings = serde_json::from_value(json!({ - "implementation":"thetradedesk", "values":{} + "implementation":"thetradedesk", "settings":{} })) .unwrap(); let original = base @@ -255,7 +255,7 @@ fn thetradedesk_construction_is_bounded_across_independent_payload_axes() { for supply_from_settings in [false, true] { let settings: BidderSettings = serde_json::from_value(json!({ "implementation":"thetradedesk", - "values":{"supply_id":if supply_from_settings { "s" } else { "ignored" }}, + "settings":{"supply_id":if supply_from_settings { "s" } else { "ignored" }}, })) .unwrap(); let mut context = BidderRequestInfo::new( diff --git a/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/conformance.rs b/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/conformance.rs index a4b7b54..3c1f974 100644 --- a/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/conformance.rs +++ b/crates/auctioneer-bidder/src/bidders/thetradedesk/tests/conformance.rs @@ -16,7 +16,7 @@ mod generated_settings_tests { #[test] fn generated_settings_keep_owned_field_and_implementation_key() { let settings: BidderSettings = serde_json::from_value(json!({ - "implementation": "thetradedesk", "values": {} + "implementation": "thetradedesk", "settings": {} })) .unwrap(); assert_eq!(settings.implementation_key(), "thetradedesk"); @@ -24,7 +24,7 @@ mod generated_settings_tests { assert!(settings.as_msft().is_none()); let wire = json!({ - "implementation": "thetradedesk", "values": {"supply_id": "reference-route"} + "implementation": "thetradedesk", "settings": {"supply_id": "reference-route"} }); let populated: BidderSettings = serde_json::from_value(wire.clone()).unwrap(); assert_eq!( @@ -37,8 +37,8 @@ mod generated_settings_tests { #[test] fn settings_refuse_public_aliases_and_another_owners_field() { for wire in [ - json!({"implementation": "ttd", "values": {}}), - json!({"implementation": "thetradedesk", "values": {"hb_source": 5_i64}}), + json!({"implementation": "ttd", "settings": {}}), + json!({"implementation": "thetradedesk", "settings": {"hb_source": 5_i64}}), ] { assert!( serde_json::from_value::(wire) @@ -121,7 +121,7 @@ mod tests { let before = serde_json::to_value(&request).unwrap(); // TestJsonSamples explicitly supplies this setting. It is not a runtime default. let settings: BidderSettings = serde_json::from_value(json!({ - "implementation":"thetradedesk", "values":{"supply_id":"ttd"}, + "implementation":"thetradedesk", "settings":{"supply_id":"ttd"}, })) .unwrap(); let mut info = BidderRequestInfo::new( diff --git a/crates/auctioneer-bidder/src/test_support/factory_contract.rs b/crates/auctioneer-bidder/src/test_support/factory_contract.rs index b371f8f..ea517cc 100644 --- a/crates/auctioneer-bidder/src/test_support/factory_contract.rs +++ b/crates/auctioneer-bidder/src/test_support/factory_contract.rs @@ -9,8 +9,8 @@ use crate::{ use auctioneer_config_data::types::MediaType; use auctioneer_openrtb::types::bid_request::BidRequest; use auctioneer_runtime::bidder_credentials::BidderCredentials; -use serde::Deserialize; -use serde_json::Value; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value}; use sha2::{Digest as _, Sha256}; use std::{collections::BTreeSet, fs, path::Path}; @@ -40,12 +40,44 @@ struct Context { endpoint_template: String, endpoint_macros: Vec, host_allowlist: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_recorded_settings")] settings: Option, #[serde(default)] credentials: Option, } +/// Normalize the historical `values` envelope without changing capture bytes. +fn deserialize_recorded_settings<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error as _; + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct RecordedSettings { + implementation: String, + values: Map, + } + + let Some(RecordedSettings { + implementation, + values, + }) = Option::::deserialize(deserializer)? + else { + return Ok(None); + }; + let document = Map::from_iter([ + ("implementation".to_owned(), Value::String(implementation)), + ("settings".to_owned(), Value::Object(values)), + ]); + serde_json::from_value(Value::Object(document)) + .map(Some) + .map_err(|_error| D::Error::custom("invalid recorded settings")) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Outbound { @@ -123,6 +155,9 @@ pub(crate) fn assert_factory_contract(key: &str, source: &str) { !case.name.is_empty() && names.insert(&case.name), "factory case names must be nonempty and unique" ); + if let Some(settings) = &case.context.settings { + assert_eq!(settings.implementation_key(), key, "factory settings owner"); + } let bidder = (registration.factory)(); let before = serde_json::to_value(&case.request).unwrap(); let macros: Vec<_> = case diff --git a/crates/auctioneer-bidder/tests/bidder_settings_compile/bidder_settings_semantics.rs b/crates/auctioneer-bidder/tests/bidder_settings_compile/bidder_settings_semantics.rs index 8e032db..fe8c21b 100644 --- a/crates/auctioneer-bidder/tests/bidder_settings_compile/bidder_settings_semantics.rs +++ b/crates/auctioneer-bidder/tests/bidder_settings_compile/bidder_settings_semantics.rs @@ -17,15 +17,15 @@ fn decode(value: Value) -> Result { serde_json::from_value(value) } -fn explicit_implementation_and_values_accept_empty_optional_fields() { - let settings = decode(json!({"implementation": "fake_integer_probe", "values": {}})).unwrap(); +fn explicit_implementation_and_settings_accept_empty_optional_fields() { + let settings = decode(json!({"implementation": "fake_integer_probe", "settings": {}})).unwrap(); assert_eq!(settings.implementation_key(), "fake_integer_probe"); let typed = settings.as_fake_integer_probe().unwrap(); assert_eq!(typed.primary_number, None); assert_eq!(typed.secondary_number, None); assert!(settings.as_fake_string_probe().is_none()); let string_settings = - decode(json!({"implementation": "fake_string_probe", "values": {}})).unwrap(); + decode(json!({"implementation": "fake_string_probe", "settings": {}})).unwrap(); assert_eq!(string_settings.implementation_key(), "fake_string_probe"); assert_eq!(string_settings.as_fake_string_probe().unwrap().token, None); assert!(string_settings.as_fake_integer_probe().is_none()); @@ -34,22 +34,24 @@ fn explicit_implementation_and_values_accept_empty_optional_fields() { fn refuses_ambiguous_missing_or_public_alias_discriminators_and_extra_fields() { for value in [ json!({}), - json!({"values": {}}), + json!({"settings": {}}), + json!({"implementation": "fake_integer_probe", "values": {}}), + json!({"implementation": "fake_integer_probe", "values": {"primary_number": 5_i64}}), json!({"primary_number": 5_i64}), json!({"implementation": "fake_integer_probe"}), - json!({"implementation": "fake_unknown", "values": {}}), - json!({"implementation": "V0", "values": {}}), - json!({"implementation": "fake_fixture_alias", "values": {}}), - json!({"implementation": "fake_integer_probe", "values": {}, "typo": true}), - json!({"implementation": "fake_integer_probe", "values": {"primry_number": 5_i64}}), - json!({"implementation": "fake_integer_probe", "values": {"token": "x"}}), - json!({"implementation": "fake_string_probe", "values": {"primary_number": 5_i64}}), - json!({"implementation": "fake_string_probe", "values": {"token_typo": "x"}}), - json!({"implementation": "fake_integer_probe", "values": null}), - json!({"implementation": "fake_integer_probe", "values": []}), - json!({"implementation": 1_i64, "values": {}}), + json!({"implementation": "fake_unknown", "settings": {}}), + json!({"implementation": "V0", "settings": {}}), + json!({"implementation": "fake_fixture_alias", "settings": {}}), + json!({"implementation": "fake_integer_probe", "settings": {}, "typo": true}), + json!({"implementation": "fake_integer_probe", "settings": {"primry_number": 5_i64}}), + json!({"implementation": "fake_integer_probe", "settings": {"token": "x"}}), + json!({"implementation": "fake_string_probe", "settings": {"primary_number": 5_i64}}), + json!({"implementation": "fake_string_probe", "settings": {"token_typo": "x"}}), + json!({"implementation": "fake_integer_probe", "settings": null}), + json!({"implementation": "fake_integer_probe", "settings": []}), + json!({"implementation": 1_i64, "settings": {}}), json!(["fake_integer_probe", {}]), - json!({"implementation": "fake_integer_probe", "values": [5_i64, 6_i64]}), + json!({"implementation": "fake_integer_probe", "settings": [5_i64, 6_i64]}), ] { assert!(decode(value).unwrap_err().is_data()); } @@ -59,7 +61,7 @@ fn optional_string_has_no_coercion_or_invented_source_regex() { for value in [json!(0_i64), json!(true), json!([]), json!({})] { assert!( decode(json!({ - "implementation": "fake_string_probe", "values": {"token": value} + "implementation": "fake_string_probe", "settings": {"token": value} })) .unwrap_err() .is_data() @@ -67,7 +69,7 @@ fn optional_string_has_no_coercion_or_invented_source_regex() { } for value in ["", "host-default", "UPPER_09", "not/path-validation"] { let settings = decode(json!({ - "implementation": "fake_string_probe", "values": {"token": value} + "implementation": "fake_string_probe", "settings": {"token": value} })) .unwrap(); assert_eq!( @@ -76,7 +78,7 @@ fn optional_string_has_no_coercion_or_invented_source_regex() { ); } let settings = decode(json!({ - "implementation": "fake_string_probe", "values": {"token": null} + "implementation": "fake_string_probe", "settings": {"token": null} })) .unwrap(); assert_eq!(settings.as_fake_string_probe().unwrap().token, None); @@ -86,7 +88,7 @@ fn optional_signed_i64_has_full_domain_and_strict_types() { for field in ["primary_number", "secondary_number"] { for value in [i64::MIN, -1, 0, 5, 6, i64::MAX] { let settings = decode(json!({ - "implementation": "fake_integer_probe", "values": { (field): value } + "implementation": "fake_integer_probe", "settings": { (field): value } })) .unwrap(); let typed = settings.as_fake_integer_probe().unwrap(); @@ -111,7 +113,7 @@ fn optional_signed_i64_has_full_domain_and_strict_types() { ] { assert!( decode(json!({ - "implementation": "fake_integer_probe", "values": { (field): value } + "implementation": "fake_integer_probe", "settings": { (field): value } })) .unwrap_err() .is_data() @@ -159,7 +161,7 @@ fn string_views_preserve_empty_override_and_borrow_inherited_storage() { fn debug_redacts_payloads_and_borrowed_views() { let settings = decode(json!({ - "implementation": "fake_string_probe", "values": {"token": "private-marker"} + "implementation": "fake_string_probe", "settings": {"token": "private-marker"} })) .unwrap(); let typed = settings.as_fake_string_probe().unwrap(); @@ -189,8 +191,8 @@ fn debug_redacts_payloads_and_borrowed_views() { } } -fn serialization_retains_explicit_discriminator_and_values() { - let original = json!({"implementation": "fake_string_probe", "values": {"token": "host"}}); +fn serialization_retains_explicit_discriminator_and_settings() { + let original = json!({"implementation": "fake_string_probe", "settings": {"token": "host"}}); let settings = decode(original.clone()).unwrap(); assert_eq!(serde_json::to_value(&settings).unwrap(), original); } @@ -207,7 +209,7 @@ fn standalone_payloads_also_require_objects_and_optional_null_stays_absent() { .is_data() ); let settings = decode(json!({ - "implementation": "fake_integer_probe", "values": {"primary_number": null, "secondary_number": null} + "implementation": "fake_integer_probe", "settings": {"primary_number": null, "secondary_number": null} })) .unwrap(); let typed = settings.as_fake_integer_probe().unwrap(); @@ -217,14 +219,14 @@ fn standalone_payloads_also_require_objects_and_optional_null_stays_absent() { fn main() { generated_endpoint_bindings_preserve_literals_and_borrow_storage(); - explicit_implementation_and_values_accept_empty_optional_fields(); + explicit_implementation_and_settings_accept_empty_optional_fields(); refuses_ambiguous_missing_or_public_alias_discriminators_and_extra_fields(); optional_string_has_no_coercion_or_invented_source_regex(); optional_signed_i64_has_full_domain_and_strict_types(); borrowed_refs_resolve_each_requested_field_before_base_without_normalization(); string_views_preserve_empty_override_and_borrow_inherited_storage(); debug_redacts_payloads_and_borrowed_views(); - serialization_retains_explicit_discriminator_and_values(); + serialization_retains_explicit_discriminator_and_settings(); standalone_payloads_also_require_objects_and_optional_null_stays_absent(); println!("settings consumer passed"); } diff --git a/crates/auctioneer-bidder/tests/bidder_settings_compile/mod.rs b/crates/auctioneer-bidder/tests/bidder_settings_compile/mod.rs index 32df067..90f783f 100644 --- a/crates/auctioneer-bidder/tests/bidder_settings_compile/mod.rs +++ b/crates/auctioneer-bidder/tests/bidder_settings_compile/mod.rs @@ -348,7 +348,7 @@ fn main() { traits::(); for key in ["fake_probe", "std", "serde", "V0"] { assert!(serde_json::from_value::(serde_json::json!({ - "implementation": key, "values": {} + "implementation": key, "settings": {} })).is_err()); } for wire in [serde_json::json!({}), serde_json::json!([]), serde_json::json!(null)] { @@ -411,7 +411,7 @@ const ERROR: &str = "invalid credential reference"; const CANARY: &str = "private-reference-canary"; fn wire(values: Value) -> Value { - json!({"implementation": "fake_credential_probe", "values": values}) + json!({"implementation": "fake_credential_probe", "settings": values}) } fn main() { @@ -553,7 +553,7 @@ pub mod generated { } macro_rules! check { ($key:literal, $module:ident, $accessor:ident) => {{ - let wire = serde_json::json!({"implementation": $key, "values": { + let wire = serde_json::json!({"implementation": $key, "settings": { "resolve": 0, "requested": 11, "base": 12, "clone": "", "fmt": "private-fmt", "deserialize": "private-deserialize" }}); @@ -635,7 +635,7 @@ pub mod expanded { macro_rules! check { ($key:literal, $module:ident, $accessor:ident) => {{ for (number, text) in [(0, ""), (i64::MIN, "min"), (i64::MAX, "max")] { - let wire = serde_json::json!({"implementation": $key, "values": { + let wire = serde_json::json!({"implementation": $key, "settings": { "number": number, "text": text }}); let before: baseline::BidderSettings = serde_json::from_value(wire.clone()).unwrap(); @@ -656,7 +656,7 @@ macro_rules! check { fn main() { check!("fake_middle_probe", fake_middle_probe, as_fake_middle_probe); check!("fake_zeta_probe", fake_zeta_probe, as_fake_zeta_probe); - let wire = serde_json::json!({"implementation": "fake_aaa_probe", "values": {"token": "new"}}); + let wire = serde_json::json!({"implementation": "fake_aaa_probe", "settings": {"token": "new"}}); assert!(serde_json::from_value::(wire.clone()).is_err()); let inserted: expanded::BidderSettings = serde_json::from_value(wire).unwrap(); assert_eq!(inserted.as_fake_aaa_probe().unwrap().token.as_deref(), Some("new")); diff --git a/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs b/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs index 0539607..ab09b44 100644 --- a/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs +++ b/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs @@ -200,7 +200,7 @@ mod tests { }; entries.insert( "fake_fx".into(), - serde_json::from_value(json!({"implementation":implementation,"values":values})) + serde_json::from_value(json!({"implementation":implementation,"settings":values})) .unwrap(), ); } @@ -212,7 +212,7 @@ mod tests { }; entries.insert( "fake_first".into(), - serde_json::from_value(json!({"implementation":implementation,"values":values})) + serde_json::from_value(json!({"implementation":implementation,"settings":values})) .unwrap(), ); } diff --git a/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs b/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs index 9c89c61..9c58f08 100644 --- a/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs +++ b/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs @@ -297,10 +297,10 @@ mod request_binding { fn settings_binding_refuses_each_wrong_or_missing_implementation_before_projection() { let mut cases = 0; let integer: BidderSettings = - serde_json::from_value(json!({"implementation":"fake_integer_probe","values":{}})) + serde_json::from_value(json!({"implementation":"fake_integer_probe","settings":{}})) .unwrap(); let string: BidderSettings = - serde_json::from_value(json!({"implementation":"fake_string_probe","values":{}})) + serde_json::from_value(json!({"implementation":"fake_string_probe","settings":{}})) .unwrap(); for (expected, requested, base) in [ (Some("fake_integer_probe"), Some(&string), None), @@ -329,11 +329,11 @@ mod request_binding { #[test] fn settings_binding_borrows_both_entries_and_preserves_fieldwise_zero_precedence() { let base: BidderSettings = serde_json::from_value( - json!({"implementation":"fake_integer_probe","values":{"primary_number":41_i64,"secondary_number":42_i64}}), + json!({"implementation":"fake_integer_probe","settings":{"primary_number":41_i64,"secondary_number":42_i64}}), ) .unwrap(); let alias: BidderSettings = serde_json::from_value( - json!({"implementation":"fake_integer_probe","values":{"primary_number":0_i64}}), + json!({"implementation":"fake_integer_probe","settings":{"primary_number":0_i64}}), ) .unwrap(); let mut info = settings_info(); @@ -358,10 +358,10 @@ mod request_binding { fn settings_binding_enforces_limits_for_programmatic_callers_and_clears_previous_binding() { let mut cases = 0; let small: BidderSettings = serde_json::from_value( - json!({"implementation":"fake_string_probe","values":{"token":"host"}}), + json!({"implementation":"fake_string_probe","settings":{"token":"host"}}), ) .unwrap(); - let large: BidderSettings = serde_json::from_value(json!({"implementation":"fake_string_probe","values":{"token":"private-marker".repeat(2048)}})).unwrap(); + let large: BidderSettings = serde_json::from_value(json!({"implementation":"fake_string_probe","settings":{"token":"private-marker".repeat(2048)}})).unwrap(); for (requested, base) in [ (Some(&large), None), (None, Some(&large)), @@ -404,17 +404,23 @@ mod retention { let mut bidders: serde_json::Map<_, _> = (0..count) .map(|index| { (format!("fake_bidder_{index}"), json!({ - "settings": {"implementation": "fake_string_probe", "values": {"token": "x".repeat(width)}} + "implementation": "fake_string_probe", "settings": {"token": "x".repeat(width)} })) }) .collect(); - bidders.insert("fake_base".to_owned(), json!({ - "settings": {"implementation": "fake_string_probe", "values": {"token": "b".repeat(width)}} - })); + bidders.insert( + "fake_base".to_owned(), + json!({ + "implementation": "fake_string_probe", "settings": {"token": "b".repeat(width)} + }), + ); for index in 0..unrelated { - bidders.insert(format!("fake_unrelated_{index}"), json!({ - "settings": {"implementation": "fake_string_probe", "values": {"token": "u".repeat(4_096)}} - })); + bidders.insert( + format!("fake_unrelated_{index}"), + json!({ + "implementation": "fake_string_probe", "settings": {"token": "u".repeat(4_096)} + }), + ); } let account_rows: serde_json::Map<_, _> = (0..accounts) .map(|index| { @@ -584,13 +590,13 @@ mod retained_owner { "fake_alias": { "enabled": false, "timeout_ms": 20, - "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 0}} + "implementation": "fake_integer_probe", "settings": {"primary_number": 0} }, - "fake_base": {"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 42, "secondary_number": 7}}}, - "fake_second_alias": {"settings": {"implementation": "fake_integer_probe", "values": {"secondary_number": 9}}}, - "fake_unused_alias": {"settings": {"implementation": "fake_string_probe", "values": {"token": "unused-alias"}}}, - "fake_unused_base": {"settings": {"implementation": "fake_string_probe", "values": {"token": "unused-base"}}}, - "fake_unrelated": {"settings": {"implementation": "fake_string_probe", "values": {"token": "unrelated"}}}, + "fake_base": {"implementation": "fake_integer_probe", "settings": {"primary_number": 42, "secondary_number": 7}}, + "fake_second_alias": {"implementation": "fake_integer_probe", "settings": {"secondary_number": 9}}, + "fake_unused_alias": {"implementation": "fake_string_probe", "settings": {"token": "unused-alias"}}, + "fake_unused_base": {"implementation": "fake_string_probe", "settings": {"token": "unused-base"}}, + "fake_unrelated": {"implementation": "fake_string_probe", "settings": {"token": "unrelated"}}, "fake_no_settings": {"enabled": false} }, "accounts": {"fake_discarded": {"targeting_prefix": "discarded"}} @@ -711,10 +717,10 @@ mod retained_owner { serde_json::from_value(json!({ "schema_version": 1, "bidders": { - "fake_alias": {"settings": {"implementation": "fake_string_probe", "values": {"token": "a".repeat(width)}}}, - "fake_fallback": {"settings": {"implementation": "fake_string_probe", "values": {}}}, - "fake_base": {"settings": {"implementation": "fake_string_probe", "values": {"token": "b".repeat(width)}}}, - "fake_unrelated": {"settings": {"implementation": "fake_string_probe", "values": {"token": "unrelated-host"}}}, + "fake_alias": {"implementation": "fake_string_probe", "settings": {"token": "a".repeat(width)}}, + "fake_fallback": {"implementation": "fake_string_probe", "settings": {}}, + "fake_base": {"implementation": "fake_string_probe", "settings": {"token": "b".repeat(width)}}, + "fake_unrelated": {"implementation": "fake_string_probe", "settings": {"token": "unrelated-host"}}, "fake_no_settings": {"enabled": false} }, "accounts": {"fake_discarded": {"targeting_prefix": "unused"}} @@ -864,15 +870,15 @@ mod config_limits { let policy = auctioneer_config::policy::OperatorPolicy::::default(); policy.validate().unwrap(); let valid = decode( - json!({"implementation":"fake_string_probe","values":{"token":"private-marker"}}), + json!({"implementation":"fake_string_probe","settings":{"token":"private-marker"}}), ) .unwrap(); valid.validate().unwrap(); - let invalid = decode(json!({"implementation":"fake_string_probe","values":{"token":"private-marker".repeat(2048)}})).unwrap(); + let invalid = decode(json!({"implementation":"fake_string_probe","settings":{"token":"private-marker".repeat(2048)}})).unwrap(); let errors = invalid.validate().unwrap_err(); let fields = errors.field_errors(); assert_eq!(fields.len(), 1); - let errors = fields["values"]; + let errors = fields["settings"]; assert_eq!(errors.len(), 1); assert_eq!(errors[0].code, "bidder settings exceed byte limit"); assert!(errors[0].params.is_empty()); @@ -883,18 +889,18 @@ mod config_limits { fn typed_settings_byte_ceiling_includes_the_envelope_and_json_escaping() { use auctioneer_bidder::bidder_settings::ensure_limits; let empty = - decode(json!({"implementation":"fake_string_probe","values":{"token":""}})).unwrap(); + decode(json!({"implementation":"fake_string_probe","settings":{"token":""}})).unwrap(); let overhead = auctioneer_runtime::serialized_json_bytes(&empty).unwrap(); let width = usize::try_from(0x4000_u64 - overhead).unwrap(); let mut cases = 0; for (extra, expected) in [(0, Ok(())), (1, Err("bidder settings exceed byte limit"))] { - let settings = decode(json!({"implementation":"fake_string_probe","values":{"token":"x".repeat(width + extra)}})).unwrap(); + let settings = decode(json!({"implementation":"fake_string_probe","settings":{"token":"x".repeat(width + extra)}})).unwrap(); assert_eq!(ensure_limits(&settings), expected); cases += 1; } assert_eq!(cases, 2); let escaped = decode( - json!({"implementation":"fake_string_probe","values":{"token":"\n".repeat(width)}}), + json!({"implementation":"fake_string_probe","settings":{"token":"\n".repeat(width)}}), ) .unwrap(); assert_eq!( @@ -979,15 +985,15 @@ mod runtime { const CANARY: &str = "private-settings-canary"; let mut cases = 0; for settings in [ - json!({"implementation":CANARY,"values":{}}), - json!({"implementation":"fake_integer_probe","values":{"primary_number":CANARY}}), - json!({"implementation":"fake_integer_probe","values":{(CANARY):true}}), - json!({"implementation":"fake_string_probe","values":{"token":CANARY.repeat(2048)}}), + json!({"implementation":CANARY,"settings":{}}), + json!({"implementation":"fake_integer_probe","settings":{"primary_number":CANARY}}), + json!({"implementation":"fake_integer_probe","settings":{(CANARY):true}}), + json!({"implementation":"fake_string_probe","settings":{"token":CANARY.repeat(2048)}}), ] { let fanout = Arc::new(RecordingFanout::default()); let ctx = context_with( Arc::new(SettingsStore( - json!({"schema_version": 1, "bidders":{"fake_bidder":{"settings":settings}}}), + json!({"schema_version": 1, "bidders":{"fake_bidder":settings}}), )), Arc::clone(&fanout), ); @@ -1207,12 +1213,12 @@ mod compiled_sdk { json!({"schema_version": 1, "host": {"aliases": {"fake_host_alias": "fake_wire_leaf"}}, "bidders": { - "fake_wire_base": {"enabled": true, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 7, "secondary_number": 11}}}, - "fake_wire_parent": {"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 3, "secondary_number": 5}}}, - "fake_wire_leaf": {"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 0, "secondary_number": 23}}}, - "fake_host_alias": {"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 0}}}, - "fake_wire_text": {"settings": {"implementation": "fake_string_probe", "values": {"token": "fake_safe"}}}, - "fake_future": {"enabled": true, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 17, "secondary_number": 19}}} + "fake_wire_base": {"enabled": true, "implementation": "fake_integer_probe", "settings": {"primary_number": 7, "secondary_number": 11}}, + "fake_wire_parent": {"implementation": "fake_integer_probe", "settings": {"primary_number": 3, "secondary_number": 5}}, + "fake_wire_leaf": {"implementation": "fake_integer_probe", "settings": {"primary_number": 0, "secondary_number": 23}}, + "fake_host_alias": {"implementation": "fake_integer_probe", "settings": {"primary_number": 0}}, + "fake_wire_text": {"implementation": "fake_string_probe", "settings": {"token": "fake_safe"}}, + "fake_future": {"enabled": true, "implementation": "fake_integer_probe", "settings": {"primary_number": 17, "secondary_number": 19}} } }) } @@ -1338,8 +1344,10 @@ mod compiled_sdk { "fake_host_alias", ] { let mut input = document(); - input["bidders"]["fake_early"] = json!({"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": -1}}}); - input["bidders"][name]["settings"] = json!({"implementation": "fake_string_probe", "values": {"token": "fake_private_credential_canary"}}); + input["bidders"]["fake_early"] = + json!({"implementation": "fake_integer_probe", "settings": {"primary_number": -1}}); + input["bidders"][name]["implementation"] = json!("fake_string_probe"); + input["bidders"][name]["settings"] = json!({"token": "fake_private_credential_canary"}); assert_parity( &input, Err(PolicyValidationError::Bidder( @@ -1359,14 +1367,13 @@ mod compiled_sdk { for bad_base in [false, true] { let mut input = document(); input["bidders"]["fake_wire_base"]["enabled"] = json!(false); - input["bidders"]["fake_host_alias"]["settings"]["values"]["secondary_number"] = - json!(29); + input["bidders"]["fake_host_alias"]["settings"]["secondary_number"] = json!(29); let name = if bad_base { "fake_wire_leaf" } else { "fake_host_alias" }; - input["bidders"][name]["settings"]["values"][field] = json!(-1); + input["bidders"][name]["settings"][field] = json!(-1); let primary = if !bad_base && field == "primary_number" { -1 } else { @@ -1400,8 +1407,8 @@ mod compiled_sdk { fn sdk_and_typed_paths_preserve_compiled_ancestry_and_per_identity_requiredness() { assert_parity(&json!({"schema_version": 1}), Ok(()), &[]); let input = json!({"schema_version": 1, "bidders": { - "fake_wire_base": {"enabled": true, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 7, "secondary_number": 11}}}, - "fake_wire_leaf": {"settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 0}}} + "fake_wire_base": {"enabled": true, "implementation": "fake_integer_probe", "settings": {"primary_number": 7, "secondary_number": 11}}, + "fake_wire_leaf": {"implementation": "fake_integer_probe", "settings": {"primary_number": 0}} }}); assert_parity( &input, @@ -1414,7 +1421,7 @@ mod compiled_sdk { let disabled_parent = json!({"schema_version": 1, "host": {"aliases": {"fake_host_alias": "fake_wire_leaf"}}, "bidders": { - "fake_wire_base": {"enabled": true, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 7, "secondary_number": 11}}}, + "fake_wire_base": {"enabled": true, "implementation": "fake_integer_probe", "settings": {"primary_number": 7, "secondary_number": 11}}, "fake_wire_parent": {"enabled": false}, "fake_wire_leaf": {"enabled": true} }}); assert_parity( @@ -1601,15 +1608,15 @@ mod config_callbacks { "schema_version": 1_u64, "host": {"aliases": {"fake_alias": "fake_base"}}, "bidders": { - "fake_base": {"settings": {"implementation": "fake_integer_probe", "values": { + "fake_base": {"implementation": "fake_integer_probe", "settings": { "primary_number": 7_i64, "secondary_number": 11_i64 - }}}, - "fake_alias": {"settings": {"implementation": "fake_integer_probe", "values": { + }}, + "fake_alias": {"implementation": "fake_integer_probe", "settings": { "primary_number": 0_i64 - }}}, - "fake_future": {"settings": {"implementation": "fake_integer_probe", "values": { + }}, + "fake_future": {"implementation": "fake_integer_probe", "settings": { "primary_number": 17_i64, "secondary_number": 19_i64 - }}} + }} } })) .unwrap(); @@ -1646,8 +1653,8 @@ mod config_callbacks { "schema_version": 1_u64, "host": {"aliases": {"fake_alias": "fake_base"}}, "bidders": { - "fake_base": {"enabled": false, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": -1_i64}}}, - "fake_alias": {"enabled": false, "settings": {"implementation": "fake_integer_probe", "values": {"primary_number": 0_i64}}} + "fake_base": {"enabled": false, "implementation": "fake_integer_probe", "settings": {"primary_number": -1_i64}}, + "fake_alias": {"enabled": false, "implementation": "fake_integer_probe", "settings": {"primary_number": 0_i64}} } })).unwrap(); assert_eq!( @@ -1669,16 +1676,16 @@ mod config_callbacks { let before = auctioneer_bidder::fake_synthetic::factory_calls(); let catalog = public_plan(true, false); let bidders = overrides(json!({ - "fake_base": {"settings": {"implementation":"fake_integer_probe", "values":{ + "fake_base": {"implementation":"fake_integer_probe", "settings":{ "primary_number":7_i64, "secondary_number":11_i64 - }}}, - "fake_alias": {"settings": {"implementation":"fake_integer_probe", "values":{ + }}, + "fake_alias": {"implementation":"fake_integer_probe", "settings":{ "primary_number":0_i64 - }}}, - "fake_future": {"enabled":true, "settings": {"implementation":"fake_integer_probe", "values":{ + }}, + "fake_future": {"enabled":true, "implementation":"fake_integer_probe", "settings":{ "primary_number":17_i64, "secondary_number":19_i64 - }}}, - "fake_text": {"settings": {"implementation":"fake_string_probe", "values":{"token":"safe"}}} + }}, + "fake_text": {"implementation":"fake_string_probe", "settings":{"token":"safe"}} })); let aliases = BTreeMap::from([("fake_alias".into(), "fake_base".into())]); validate_bidder_configuration(&bidders, &aliases, &catalog).unwrap(); @@ -1732,8 +1739,8 @@ mod config_callbacks { let bad = json!({(field):-1_i64}); let (base, alias) = if bad_base { (bad, good) } else { (good, bad) }; let bidders = overrides(json!({ - "fake_base": {"enabled":false, "settings":{"implementation":"fake_integer_probe", "values":base}}, - "fake_alias": {"enabled":false, "settings":{"implementation":"fake_integer_probe", "values":alias}} + "fake_base": {"enabled":false, "implementation":"fake_integer_probe", "settings":base}, + "fake_alias": {"enabled":false, "implementation":"fake_integer_probe", "settings":alias} })); assert!(observations().is_empty()); assert_eq!( @@ -1778,7 +1785,7 @@ mod config_callbacks { } let catalog = public_plan(true, false); let bidders = overrides( - json!({"fake_base":{"settings":{"implementation":"fake_integer_probe", "values":{"primary_number":0_i64}}}}), + json!({"fake_base":{"implementation":"fake_integer_probe", "settings":{"primary_number":0_i64}}}), ); assert_eq!( validate_bidder_configuration(&bidders, &aliases, &catalog), @@ -1800,7 +1807,7 @@ mod config_callbacks { } let catalog = public_plan(false, false); let bidders = overrides( - json!({"fake_future":{"enabled":true, "settings":{"implementation":"fake_integer_probe", "values":{}}}}), + json!({"fake_future":{"enabled":true, "implementation":"fake_integer_probe", "settings":{}}}), ); validate_bidder_configuration(&bidders, &aliases, &catalog).unwrap(); assert_eq!( @@ -1823,8 +1830,8 @@ mod config_callbacks { let aliases = BTreeMap::from([("fake_alias".into(), "fake_base".into())]); for name in ["fake_alias", "fake_base", "fake_plain"] { let mut bidders = overrides(json!({ - "fake_base":{"settings":{"implementation":"fake_integer_probe", "values":{"primary_number":3_i64,"secondary_number":5_i64}}}, - "fake_early":{"settings":{"implementation":"fake_integer_probe", "values":{"primary_number":7_i64,"secondary_number":11_i64}}} + "fake_base":{"implementation":"fake_integer_probe", "settings":{"primary_number":3_i64,"secondary_number":5_i64}}, + "fake_early":{"implementation":"fake_integer_probe", "settings":{"primary_number":7_i64,"secondary_number":11_i64}} })); bidders.insert( name.into(), @@ -1903,9 +1910,8 @@ mod config_callbacks { ); for enabled in [false, true] { let bidders = overrides(json!({ - "fake_future":{"enabled":enabled, "settings":{ - "implementation":"fake_integer_probe", "values":{"primary_number":0_i64} - }} + "fake_future":{"enabled":enabled, "implementation":"fake_integer_probe", "settings":{"primary_number":0_i64} + } })); assert_eq!( validate_bidder_configuration(&bidders, &BTreeMap::new(), &catalog), @@ -1921,8 +1927,8 @@ mod config_callbacks { let before = auctioneer_bidder::fake_synthetic::factory_calls(); let catalog = public_plan(true, false); let bidders = overrides(json!({ - "fake_base":{"settings":{"implementation":"fake_integer_probe", "values":{"primary_number":3_i64,"secondary_number":5_i64}}}, - "fake_text":{"enabled":false, "settings":{"implementation":"fake_string_probe", "values":{"token":"private_canary".repeat(2048)}}} + "fake_base":{"implementation":"fake_integer_probe", "settings":{"primary_number":3_i64,"secondary_number":5_i64}}, + "fake_text":{"enabled":false, "implementation":"fake_string_probe", "settings":{"token":"private_canary".repeat(2048)}} })); let error = validate_bidder_configuration(&bidders, &BTreeMap::new(), &catalog).unwrap_err(); @@ -1938,8 +1944,8 @@ mod config_callbacks { let canary = "fake_private_credential_location"; let aliases = BTreeMap::from([("fake_alias".into(), "fake_text".into())]); let bidders = overrides(json!({ - "fake_text":{"settings":{"implementation":"fake_string_probe", "values":{"token":canary}}}, - "fake_alias":{"settings":{"implementation":"fake_string_probe", "values":{"token":""}}} + "fake_text":{"implementation":"fake_string_probe", "settings":{"token":canary}}, + "fake_alias":{"implementation":"fake_string_probe", "settings":{"token":""}} })); let error = validate_bidder_configuration(&bidders, &aliases, &catalog).unwrap_err(); assert_eq!( @@ -1973,7 +1979,7 @@ mod config_callbacks { } fn settings(implementation: &str, values: Value) -> BidderSettings { - serde_json::from_value(json!({"implementation":implementation, "values":values})).unwrap() + serde_json::from_value(json!({"implementation":implementation, "settings":values})).unwrap() } fn integer(values: Value) -> BidderSettings { diff --git a/crates/auctioneer-cli/src/application.rs b/crates/auctioneer-cli/src/application.rs index 0b4698c..516d090 100644 --- a/crates/auctioneer-cli/src/application.rs +++ b/crates/auctioneer-cli/src/application.rs @@ -427,15 +427,11 @@ mod config_contract { "sample_rate", "max_transport_bytes", "max_decoded_bytes", + "implementation", "settings", ], ); - assert_reference_fields( - &example, - "bidders.fake_bidder.settings", - &["implementation", "values"], - ); - let values = example["bidders"]["fake_bidder"]["settings"]["values"] + let values = example["bidders"]["fake_bidder"]["settings"] .as_table() .unwrap(); let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -465,29 +461,41 @@ mod config_contract { values.keys().map(String::as_str).collect::>(), declared ); + let expected_roundtrips = owners.len(); + let mut roundtrips = 0_usize; let mut checked = 0_usize; for (owner, settings) in owners { - let Some(name) = owner + let compiled_name = owner .definitions .keys() - .find(|name| auctioneer_config_data::bidder(name).is_some()) - else { - continue; - }; + .find(|name| auctioneer_config_data::bidder(name).is_some()); + let name = compiled_name.unwrap_or(&owner.key); let selected: toml::Table = settings .fields .keys() .map(|field| (field.clone(), values[field].clone())) .collect(); let mut overrides = serde_json::to_value(&example["bidders"]["fake_bidder"]).unwrap(); - overrides["settings"] = json!({"implementation": owner.key, "values": selected}); - let policy: SdkAuctioneerAppConfig = serde_json::from_value( - json!({"schema_version": 1_u8, "bidders": {(name): overrides}}), - ) - .unwrap(); - policy.validate().unwrap(); - checked = checked.checked_add(1).unwrap(); + overrides["implementation"] = json!(owner.key); + overrides["settings"] = json!(selected); + let wire = json!({"schema_version": 1_u8, "bidders": {(name): overrides}}); + let policy: SdkAuctioneerAppConfig = serde_json::from_value(wire.clone()).unwrap(); + let toml_policy: SdkAuctioneerAppConfig = + toml::from_str(&toml::to_string(&wire).unwrap()).unwrap(); + // Reference schemas cover every discovered owner. Admission + // additionally requires the owner in this feature lane's build. + if compiled_name.is_some() { + policy.validate().unwrap(); + toml_policy.validate().unwrap(); + checked = checked.checked_add(1).unwrap(); + } + let serialized = serde_json::to_value(&policy).unwrap(); + assert_eq!(serialized, serde_json::to_value(toml_policy).unwrap()); + assert_eq!(serialized["bidders"][name]["implementation"], owner.key); + assert_eq!(serialized["bidders"][name]["settings"], json!(selected)); + roundtrips = roundtrips.checked_add(1).unwrap(); } + assert_eq!(roundtrips, expected_roundtrips); assert!(checked > 0, "compiled settings examples must execute"); } diff --git a/crates/auctioneer-config/src/bidder_settings_codegen.rs b/crates/auctioneer-config/src/bidder_settings_codegen.rs index f964a08..7243e00 100644 --- a/crates/auctioneer-config/src/bidder_settings_codegen.rs +++ b/crates/auctioneer-config/src/bidder_settings_codegen.rs @@ -52,7 +52,7 @@ pub fn render(manifests: &BTreeMap) -> Result) -> Result) -> Result) -> FmtResult { f.debug_struct("BidderSettings") .field("implementation", &self.implementation_key()) - .field("values", &"").finish() + .field("settings", &"").finish() } } }.to_string()) diff --git a/crates/auctioneer-config/src/bidder_settings_wire.rs b/crates/auctioneer-config/src/bidder_settings_wire.rs new file mode 100644 index 0000000..0831653 --- /dev/null +++ b/crates/auctioneer-config/src/bidder_settings_wire.rs @@ -0,0 +1,88 @@ +//! Strictly claim the bidder selector and its separate typed settings payload. + +use core::{fmt, marker::PhantomData}; +use serde::{ + Deserialize, Deserializer, + de::{ + DeserializeSeed, MapAccess, Visitor, value::MapAccessDeserializer, value::StrDeserializer, + }, +}; + +#[derive(Deserialize)] +#[serde(field_identifier, rename_all = "snake_case")] +enum Key { + Implementation, + Settings, +} + +impl Key { + fn as_str(&self) -> &'static str { + match self { + Self::Implementation => "implementation", + Self::Settings => "settings", + } + } +} + +/// A flattened `Option` suppresses deserialization errors. Claim exactly the +/// envelope keys as a struct instead, returning absence only for an empty map. +pub(crate) fn deserialize_optional<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct SettingsVisitor(PhantomData T>); + impl<'de, T: Deserialize<'de>> Visitor<'de> for SettingsVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bidder implementation and settings object") + } + + fn visit_map>(self, mut map: A) -> Result { + let Some(first) = map.next_key::()? else { + return Ok(None); + }; + T::deserialize(MapAccessDeserializer::new(PrefixedMap { + first: Some(first), + map, + })) + .map(Some) + } + } + // deserialize_struct consumes only these two keys from Serde's flattened + // map. The outer strict bidder model rejects any unclaimed row fields. + deserializer.deserialize_struct( + "BidderSettings", + &["implementation", "settings"], + SettingsVisitor(PhantomData), + ) +} + +struct PrefixedMap { + first: Option, + map: A, +} + +impl<'de, A: MapAccess<'de>> MapAccess<'de> for PrefixedMap { + type Error = A::Error; + + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Self::Error> { + if let Some(key) = self.first.take() { + return seed + .deserialize(StrDeserializer::::new(key.as_str())) + .map(Some); + } + self.map.next_key_seed(seed) + } + + fn next_value_seed>( + &mut self, + seed: V, + ) -> Result { + self.map.next_value_seed(seed) + } +} diff --git a/crates/auctioneer-config/src/lib.rs b/crates/auctioneer-config/src/lib.rs index 0383630..d643053 100644 --- a/crates/auctioneer-config/src/lib.rs +++ b/crates/auctioneer-config/src/lib.rs @@ -19,6 +19,8 @@ mod object; #[cfg(feature = "config-model")] pub use currency::{FloorCurrencyConfig, MAX_FLOOR_CURRENCY_RATES}; #[cfg(feature = "config-model")] +mod bidder_settings_wire; +#[cfg(feature = "config-model")] mod model_validation; #[cfg(feature = "config-model")] pub mod override_value; diff --git a/crates/auctioneer-config/src/source.rs b/crates/auctioneer-config/src/source.rs index ef062f8..3882e86 100644 --- a/crates/auctioneer-config/src/source.rs +++ b/crates/auctioneer-config/src/source.rs @@ -1026,11 +1026,12 @@ pub struct FeatureFlags { #[derive(Debug, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields, bound(deserialize = "Settings: Deserialize<'de>"))] pub struct BidderOverrides { - /// Implementation-tagged, bidder-owned host protocol settings. This does - /// not activate an implementation or authorize an endpoint. + /// Bidder-owned settings, serialized as sibling `implementation` and + /// `settings` fields. Selection does not activate an implementation or + /// authorize an endpoint. #[serde( - default, - deserialize_with = "deserialize_optional_value", + flatten, + deserialize_with = "crate::bidder_settings_wire::deserialize_optional", skip_serializing_if = "Option::is_none" )] #[validate(nested)] @@ -1838,9 +1839,16 @@ pub(crate) mod test_support { use validator::{Validate, ValidationError, ValidationErrors}; // Deliberately not Default: optional settings never synthesize a value. + #[derive(Debug, Deserialize, Serialize)] + #[serde(tag = "implementation", content = "settings", deny_unknown_fields)] + pub(crate) enum TestSettings { + #[serde(rename = "fake_private_bidder")] + Private(PrivateSettings), + } + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] - pub(crate) struct TestSettings { + pub(crate) struct PrivateSettings { private_value: String, } @@ -1860,6 +1868,80 @@ pub(crate) mod test_support { } } + #[test] + fn bidder_entry_selects_separate_settings_with_strict_pairing() { + let wire = json!({ + "enabled": false, + "implementation": "fake_private_bidder", + "settings": {"private_value": "private-settings-canary"}, + }); + let entry: BidderOverrides = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(entry.enabled, Some(false)); + assert!(entry.settings.is_some()); + assert_eq!(serde_json::to_value(&entry).unwrap(), wire); + let from_toml: BidderOverrides = + toml::from_str(&toml::to_string(&wire).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(from_toml).unwrap(), wire); + } + + #[test] + fn bidder_entry_never_discards_invalid_present_settings() { + for wire in [ + json!({"settings": {"private_value": "private-canary"}}), + json!({"implementation": "fake_private_bidder"}), + json!({"implementation": "private-canary", "settings": {"private_value": "private-canary"}}), + json!({"implementation": null, "settings": {"private_value": "private-canary"}}), + json!({"implementation": 7_i64, "settings": {"private_value": "private-canary"}}), + json!({"implementation": "fake_private_bidder", "settings": null}), + json!({"implementation": "fake_private_bidder", "settings": []}), + json!({"implementation": "fake_private_bidder", "settings": {"private_value": 7_i64}}), + json!({"implementation": "fake_private_bidder", "settings": {"private_value": "private-canary", "unknown": true}}), + json!({"implementation": "fake_private_bidder", "settings": {"private_value": "private-canary"}, "unknown": true}), + json!({"settings": {"implementation": "fake_private_bidder", "values": {"private_value": "private-canary"}}}), + json!({"enabled": null}), + json!({"unknown": true}), + json!([]), + ] { + serde_json::from_value::>(wire) + .expect_err("invalid present settings must not become absence"); + } + } + + #[test] + fn bidder_entry_preserves_key_order_independence_and_duplicate_refusal() { + use serde::Deserialize as _; + use serde::de::value::MapDeserializer; + for settings_first in [false, true] { + let mut fields = vec![ + ("implementation", json!("fake_private_bidder")), + ("settings", json!({"private_value": "private-canary"})), + ]; + if settings_first { + fields.reverse(); + } + fields.insert(1, ("enabled", json!(false))); + let entry = + BidderOverrides::::deserialize( + MapDeserializer::<_, serde_json::Error>::new(fields.clone().into_iter()), + ) + .unwrap(); + assert!(entry.settings.is_some()); + assert_eq!(entry.enabled, Some(false)); + for duplicate in [ + ("implementation", json!("fake_private_bidder")), + ("settings", json!({"private_value": "private-canary"})), + ("enabled", json!(false)), + ] { + let mut repeated = fields.clone(); + repeated.push(duplicate); + BidderOverrides::::deserialize( + MapDeserializer::<_, serde_json::Error>::new(repeated.into_iter()), + ) + .expect_err("duplicate bidder controls and envelope keys must refuse"); + } + } + } + #[test] fn generic_roots_and_entries_do_not_require_default_settings() { let default_policy = OperatorPolicy::::default(); @@ -1905,6 +1987,7 @@ pub(crate) mod test_support { fn supplied_entry_settings_are_visited_once_with_fixed_nested_errors() { VISITS.set(0); let entry: BidderOverrides = serde_json::from_value(json!({ + "implementation": "fake_private_bidder", "settings": {"private_value": "private-settings-canary"} })) .unwrap(); @@ -1928,7 +2011,7 @@ pub(crate) mod test_support { fn supplied_source_settings_are_visited_once_without_input_in_errors() { VISITS.set(0); let source: OperatorPolicy = toml::from_str( - "schema_version = 1\n[bidders.fake_private_bidder_canary.settings]\nprivate_value = \"private-settings-canary\"\n", + "schema_version = 1\n[bidders.fake_private_bidder_canary]\nimplementation = 'fake_private_bidder'\n[bidders.fake_private_bidder_canary.settings]\nprivate_value = \"private-settings-canary\"\n", ) .unwrap(); let errors = source @@ -1949,6 +2032,7 @@ pub(crate) mod test_support { let policy: OperatorPolicy = serde_json::from_value(json!({ "schema_version": 1_u32, "bidders": {"fake_private_bidder_canary": { + "implementation": "fake_private_bidder", "settings": {"private_value": "private-settings-canary"} }} })) diff --git a/crates/auctioneer-core/tests/bidder_settings_conformance.rs b/crates/auctioneer-core/tests/bidder_settings_conformance.rs index 9e87449..c0c49df 100644 --- a/crates/auctioneer-core/tests/bidder_settings_conformance.rs +++ b/crates/auctioneer-core/tests/bidder_settings_conformance.rs @@ -27,24 +27,32 @@ mod tests { if kind.as_str() != Some("optional_string") { continue; } + let owner_settings = json!({field:"private-host-marker"}); let input = json!({ "schema_version": 1_u64, "bidders": { "fake_future_alias": { - "settings": { - "implementation": manifest["key"].as_str().unwrap(), - "values": {field:"private-host-marker"}, - }, + "implementation": manifest["key"].as_str().unwrap(), + "settings": owner_settings, }, }, }); let source = toml::to_string(&input).unwrap(); let config: AuctioneerAppConfig = toml::from_str(&source).unwrap(); + let json_config: AuctioneerAppConfig = serde_json::from_value(input).unwrap(); assert!(!format!("{config:?}").contains("private-host-marker")); assert_eq!( - serde_json::to_value(&config).unwrap()["bidders"]["fake_future_alias"]["settings"] - ["values"][field], - "private-host-marker" + serde_json::to_value(&config).unwrap(), + serde_json::to_value(json_config).unwrap(), + ); + let serialized = serde_json::to_value(&config).unwrap(); + assert_eq!( + serialized["bidders"]["fake_future_alias"]["implementation"], + manifest["key"].as_str().unwrap(), + ); + assert_eq!( + serialized["bidders"]["fake_future_alias"]["settings"], + owner_settings, ); checked += 1; } @@ -54,4 +62,98 @@ mod tests { "runtime settings redaction coverage must not be empty" ); } + + #[test] + fn supplied_settings_require_the_bidder_row_owner_and_a_separate_subconfig() { + let omitted = json!({ + "schema_version": 1_u64, + "bidders": {"fake_future_alias": {}}, + }); + let omitted_json: AuctioneerAppConfig = serde_json::from_value(omitted.clone()).unwrap(); + let omitted_toml: AuctioneerAppConfig = + toml::from_str(&toml::to_string(&omitted).unwrap()).unwrap(); + assert!(omitted_json.bidders["fake_future_alias"].settings.is_none()); + assert!(omitted_toml.bidders["fake_future_alias"].settings.is_none()); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../auctioneer-bidder/src/bidders"); + let mut owners = 0_usize; + let mut refusals = 0_usize; + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path().join("bidder.toml"); + if !path.is_file() { + continue; + } + let manifest: toml::Value = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + let Some(fields) = manifest + .get("settings") + .and_then(|settings| settings.get("fields")) + .and_then(toml::Value::as_table) + else { + continue; + }; + let owner = manifest["key"].as_str().unwrap(); + let owner_settings: serde_json::Map<_, _> = fields + .iter() + .map(|(field, kind)| { + let value = match kind.as_str().unwrap() { + "optional_string" => json!("fake_value"), + "optional_i64" => json!(7_i64), + "optional_secret_reference" => json!("fake_secret_bundle"), + other => panic!("unsupported declared settings kind: {other}"), + }; + (field.clone(), value) + }) + .collect(); + let mut nested_owner = owner_settings.clone(); + nested_owner.insert("implementation".to_owned(), json!(owner)); + for row in [ + json!({"settings":{}}), + json!({"settings":owner_settings}), + json!({"settings":nested_owner}), + json!({"implementation":owner, "settings":{"values":owner_settings}}), + json!({"implementation":owner, "settings":{"fake_unknown_settings_field":{"fake_value":true}}}), + ] { + let input = json!({ + "schema_version": 1_u64, + "bidders": {"fake_future_alias": row}, + }); + serde_json::from_value::(input.clone()).expect_err( + "supplied settings require the bidder row owner and owner-only fields", + ); + toml::from_str::(&toml::to_string(&input).unwrap()) + .expect_err("TOML uses the same bidder row owner and settings subconfig"); + refusals += 1; + } + + let empty = json!({ + "schema_version": 1_u64, + "bidders": {"fake_future_alias": {"implementation":owner, "settings":{}}}, + }); + let from_json: AuctioneerAppConfig = serde_json::from_value(empty.clone()).unwrap(); + let from_toml: AuctioneerAppConfig = + toml::from_str(&toml::to_string(&empty).unwrap()).unwrap(); + assert_eq!( + from_json.bidders["fake_future_alias"] + .settings + .as_ref() + .unwrap() + .implementation_key(), + owner, + ); + assert_eq!( + serde_json::to_value(&from_json).unwrap(), + serde_json::to_value(from_toml).unwrap(), + ); + assert_eq!( + serde_json::to_value(from_json).unwrap()["bidders"]["fake_future_alias"], + empty["bidders"]["fake_future_alias"], + ); + owners += 1; + } + assert!( + owners > 0, + "settings owner boundary coverage must not be empty" + ); + assert_eq!(refusals, owners * 5); + } } diff --git a/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs b/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs index 16b86b1..6eb388d 100644 --- a/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs +++ b/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs @@ -239,11 +239,22 @@ fn settings_refuse_policy_and_secret_failures_preserve_siblings(app: &App, cohor ); let mut missing_settings = runtime.clone(); + assert!( + missing_settings["bidders"][target.name()] + .get("settings") + .is_some() + ); + assert!( + missing_settings["bidders"][target.name()] + .get("implementation") + .is_some() + ); for ancestor in &target.ancestry { - missing_settings["bidders"][&ancestor.name] + let row = missing_settings["bidders"][&ancestor.name] .as_object_mut() - .unwrap() - .remove("settings"); + .unwrap(); + row.remove("implementation"); + row.remove("settings"); } let settings_secrets = Secrets::new(&requested, false); let settings_answer = send_rejected_policy( diff --git a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs index e3c1797..896a7d3 100644 --- a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs +++ b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs @@ -291,6 +291,25 @@ fn flow_from_member( .into_iter() .next() .expect("owner first factory case"); + if let Some(recorded) = case + .context + .get("settings") + .filter(|value| !value.is_null()) + { + // Preserve the historical capture; its owner and fields move to the + // bidder row and settings subconfig only when creating runtime input. + let recorded = recorded.as_object().expect("recorded settings object"); + assert_eq!( + recorded.keys().map(String::as_str).collect::>(), + BTreeSet::from(["implementation", "values"]), + ); + assert_eq!( + recorded["implementation"].as_str(), + Some(member.implementation.as_str()), + "recorded settings belong to their fixture owner", + ); + assert!(recorded["values"].is_object()); + } assert!(!case.outbound.is_empty()); let ids: BTreeMap<_, _> = case.request["imp"] .as_array() @@ -709,8 +728,10 @@ pub(super) fn config(flows: &[&Flow]) -> Value { .entry(ancestor.name.clone()) .or_insert_with(|| json!({"enabled":true})); } - if let Some(settings) = flow.settings() { - bidders.get_mut(flow.name()).unwrap()["settings"] = settings.clone(); + if let Some(recorded) = flow.settings() { + let row = bidders.get_mut(flow.name()).unwrap(); + row["implementation"] = recorded["implementation"].clone(); + row["settings"] = recorded["values"].clone(); } } json!({ diff --git a/docs/bidders/controlled-native.md b/docs/bidders/controlled-native.md index bb2b165..cf04c05 100644 --- a/docs/bidders/controlled-native.md +++ b/docs/bidders/controlled-native.md @@ -22,14 +22,17 @@ schema_version = 1 [bidders.msft] enabled = true +implementation = "msft" + +[bidders.msft.settings] +hb_source = 5 +hb_source_video = 6 [bidders.thetradedesk] enabled = true - -[bidders.thetradedesk.settings] implementation = "thetradedesk" -[bidders.thetradedesk.settings.values] +[bidders.thetradedesk.settings] supply_id = "operator-issued-supply-id" [bidders.ttd] @@ -37,20 +40,21 @@ enabled = true [bidders.rubicon] enabled = true - -[bidders.rubicon.settings] implementation = "rubicon" -[bidders.rubicon.settings.values] +[bidders.rubicon.settings] region = "us-east" credentials_secret = "operator-managed-credential-bundle" ``` `ttd` retains compiled ancestry to `thetradedesk`; both must be enabled. -Disabling the canonical base also disables its compiled aliases. Settings use -the implementation key, not a request-supplied implementation. Rubicon requires -one of `us-east`, `us-west`, `eu`, or `apac` and an atomic secret-store credential -bundle; missing settings or credentials refuse that bidder, not valid siblings. +Disabling the canonical base also disables its compiled aliases. When settings +are supplied, the bidder row requires `implementation`; the separate `.settings` +subconfig contains only that owner's fields. The key must match the resolved +bidder owner. Omit both fields to use inherited or compiled defaults. Rubicon +requires one of `us-east`, `us-west`, `eu`, or `apac` and an atomic secret-store +credential bundle; missing settings or credentials refuse that bidder, not valid +siblings. Never place credential values in runtime configuration. See the existing [publisher pilot handoff](../deployment/publisher-pilot.md) and [cookie-key provisioning](../deployment/usersync-cookie-keys.md). diff --git a/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md index 1413e15..3fdbd48 100644 --- a/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md +++ b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md @@ -1,104 +1,51 @@ -# Flat bidder settings implementation plan +# Bidder implementation and settings subconfig plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** Use superpowers:subagent-driven-development or superpowers:executing-plans. Follow the maintainer's final exact table structure. -**Goal:** Accept and emit bidder-owned fields directly under `.settings` for all implementations while retaining operator binding, validation and resource contracts. +**Goal:** Place required `implementation` beside bidder controls and retain a +separate, strictly typed `.settings` subconfig for every owner. -**Architecture:** One generated opaque settings object owns a bounded vector of closed scalar fields. Generated typed views borrow it; admission resolves the operator owner, and request projection pins that same owner independently of request aliases. The user-approved flat layout replaces the pre-release wrapped authoring format. +**Architecture:** Keep the original adjacent-tag enum and owner types, renaming +its content key to `settings`. Flatten that envelope at the shared bidder-entry +boundary with a strict private deserialization adapter that propagates errors. -**Tech Stack:** Existing Rust workspace, Serde, quote/syn code generation, existing config/owner/exchange test owners; no new dependencies. +**Tech stack:** Existing Rust, Serde, quote/syn and test owners; no new dependencies. **Design:** `docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md`. -## Task 1: Generate flat storage and borrowed owner views - -**Files:** -- Modify `crates/auctioneer-config/src/bidder_settings_codegen.rs`. -- Modify `crates/auctioneer-bidder/src/bidder_settings.rs` only if reusable scalar decoding belongs outside emitted code. -- Adapt settings consumers/tests under `crates/auctioneer-bidder/src/bidders/{msft,thetradedesk,rubicon,mocktioneer}/`. -- Adapt `crates/auctioneer-bidder/tests/bidder_settings_compile/{mod.rs,bidder_settings_semantics.rs}` and relevant compiler fixtures through quote/syn. - -- [ ] Add failing tests for flat JSON/TOML objects, canonical flat output, explicit null preservation, duplicate/unknown fields and independent scalar type/refusal boundaries. Confirm the old decoder rejects the requested flat shape. -- [ ] Generate a private owned field vector, closed key enum and scalar string/i64/null variants. Decode only objects, reject duplicates/coercions and retain explicit null keys. Stable output follows generated key order. Keep both scalar strings and keys bounded by existing settings/config admission. -- [ ] Generate these shared APIs (fixed diagnostics on failure): - -```rust -impl BidderSettings { - pub fn matches_implementation(&self, key: &str) -> bool; - pub fn matching_implementations(&self) -> impl Iterator + '_; - pub fn implementation_key(&self) -> Option<&'static str>; // pinned owner only - pub fn is_empty(&self) -> bool; // zero keys, not all-null - pub fn is_unbound_empty(&self) -> bool; - pub fn bind_implementation(&mut self, key: &'static str) -> Result<(), &'static str>; - pub fn as_mocktioneer(&self) -> Option>; - // Other owner accessors are discovered, not handwritten. -} -``` - -`matches_implementation` requires a declared schema, compatible supplied keys, -exact scalar kinds/secret reference rules and a compatible internal pin. A failed -rebind must leave an explicitly invalid object whose matches/accessors all refuse -and whose serialization fails; it must not silently clear or replace the old pin. -`is_unbound_empty` excludes this invalid state. Internal pins never serialize. - -- [ ] Generate each owner `Settings<'a>` as a `Clone + Copy` thin borrow with typed getters; string getters return `Option<&'a str>`, integer getters `Option`. Generate `resolve(requested: Option>, base: Option>) -> SettingsRef<'a>` with unchanged per-field fallback. No temporary references or string copies. -- [ ] Update owner-local consumers to call getters and store views by value. Preserve semantic validators and configured endpoint checks; update inline fixtures to flat fields without changing protocol captures or manifest provenance. -- [ ] Verify generator units and owner units/compile controls. Cover empty catalogs, integer-only/string owners, shared names with different scalar kinds, explicit null, and a newly declared synthetic owner. Keep fake ownership and exact dispatch assertions. - -## Task 2: Preserve admission and request-time implementation binding - -**Files:** -- Modify `crates/auctioneer-core/src/config/validation.rs`. -- Modify `crates/auctioneer-core/src/config/bidder_settings.rs`. -- Modify `crates/auctioneer-core/src/auction/prepare.rs`. -- Modify `crates/auctioneer-bidder/src/request_info.rs`. -- Extend `crates/auctioneer-bidder/tests/support/generated_settings_contract.rs` and its existing compiler harness in `tests/generated_settings.rs`. -- Adapt other existing fixtures only where the owner API/wire format changed. - -- [ ] Write synthetic regression cases for two owners sharing `token`, host aliases `x -> alpha` and `alpha -> beta`, settings on alpha, and request aliases retargeting a configured name. Confirm binding failures precede all callbacks, credentials and body closures, without failing valid siblings or mutating source input. -- [ ] Keep the first admission pass over the complete identity union. For each requested and fallback settings row, independently derive its operator owner from effective host aliases, compiled definitions, or a unique compatible linked settings-owning implementation for an unknown request-only identity. Compare that row owner against the context's expected owner before callbacks. -- [ ] Empty unknown request-only objects may remain unbound. Known settings-free owners reject any supplied settings, including `{}`. Unknown nonempty no-match or ambiguous schema candidates fail with the existing fixed binding category. Explicit null keys participate in matching and survive round trips. -- [ ] After the entire binding/schema pass succeeds, invoke existing semantic callbacks with borrowed typed views. Preserve intrinsic versus effective requiredness and disabled/shadowed validation. -- [ ] Extend projection with trusted operator identity inputs: - -```rust -pub fn project( - policy: &AuctioneerAppConfig, - requested: &[BidderName], - aliases: &AliasMap, - operator_aliases: &BTreeMap, - bindings: &[BidderImplementationBinding], -) -> Self; -``` - -The request alias map selects retained rows only. For each selected row derive -its pin from its independent operator context, never from a request alias target. -Resolve the effective host map before this call and pass `services.bidder_bindings`. -Only copies already required by existing projection ownership are permitted. -Retain a failed bind as a refusing/invalid settings object, never omit it. - -- [ ] Update `BidderRequestInfo::set_settings` to fallibly check both settings objects' owner pins, schema, kinds, references and limits before exposing either view. Clear previous bindings before any failure as currently required. Direct trusted callers can bind an unbound compatible object through the explicit implementation argument; an existing pin is immutable. -- [ ] Update generated contract fixtures through their ordinary owner modules and quote/syn wiring. Maintain independent expected values and workload sizes. Record actual executed dispatch counts; adjust reconciliation only for intentionally added test names, never to accept zero dispatch. - -## Task 3: Update the operator contract and verify the full affected boundary - -**Files:** -- Modify active examples in `README.md`, `auctioneer.toml`, `docs/bidders/controlled-native.md` and current deployment guidance if it contains the removed wrapper. -- Modify active CLI/config/integration fixtures that use tagged settings. -- Update the Mocktioneer design/plan with a dated superseding-format note and link; do not rewrite historical approvals, evidence or external captures. -- Preserve root-local operator configuration; update only the task-created local Mocktioneer config to the requested flat form when validation supports it. - -- [ ] Exercise the exact requested TOML shape and equivalent flat JSON for each existing implementation. Parse/push with the actual CLI; emitted config contains neither removed wrapper field. Check hostile Mocktioneer URLs and invalid shadowed/base values still refuse with redacted errors. -- [ ] Run focused generator and owner tests, both bidder/exchange feature lanes and public endpoint/settings contracts. Run the explicit generated-settings compiler owner, its allocation variants and config peak/lifecycle/SDK extraction controls from `docs/testing/development.md`. -- [ ] Run both Axum envelope lanes, both CLI `bidders_resources` lanes and exact native construction charge owners. No ceilings, caps, workload reductions, frozen total edits or unreviewed evidence recaptures. -- [ ] Run strict affected library/test Clippy, Fastly WASIp1 guest build/lint and actual guest settings/publication controls as applicable. Serialize heavy Cargo work and retain the existing target cache. -- [ ] Run format, source-import isolation, raw JSON fixture gate and diff checks. Read independent spec/implementation review findings and fix concrete issues. -- [ ] Commit and update the existing requested PR with an imperative title and description describing the final config behavior, reviewed design and actual verification. No deployment, merge or public review submission. Inspect the new CI run separately and report pending/blocked checks honestly. - -## Review and authorization - -The maintainer explicitly selected the flat `.settings` contract for every -bidder in the current task. Implementation choices preserve the existing -operator/request trust boundaries. The independent spec review's findings are -incorporated above; implementation and final review must check the concrete -alias, null, type and memory cases, not merely the example syntax. +## Tasks + +- [x] Add a focused generic model test using the final sibling selector/settings + layout and observe the old entry reject the unknown `implementation` row field. +- [x] Preserve concrete bidder APIs and adjacent tagging; rename `values` to + `settings`. Add a private strict adapter claiming only the two envelope keys. + Return absence only when both are omitted; propagate every supplied error. +- [x] Exercise real bidder-entry JSON/TOML round trips, both key orders, duplicate + selector/settings/common controls, partial/null/malformed pairs and unknowns. +- [x] Migrate owner/compiler fixtures, synthetic shared contracts, README, root + reference, controlled-native guide and CLI/core examples to the exact layout. + Preserve historical captures via strict test-boundary conversion only. +- [x] Run focused generator/owner checks, both bidder/exchange feature lanes, + synthetic compiler/SDK allocation variants, public contracts, config + peak/lifecycle checks, native onboarding and both Axum envelope lanes. +- [x] Run strict native and Fastly guest lint, guest publication/storage controls, + Fastly release build, actual CLI/Viceroy adoption and both CLI resource lanes. +- [x] Update only the task-created local config, preserving its real endpoint; + never commit it. Run format, raw JSON, source isolation and diff checks. +- [x] Complete independent review of the implementation and test migrations. +- Commit and update requested PR #160, then inspect its new CI. Publication + and CI results are recorded on the PR. No deployment, merge or public review submission. + +## Verification discipline + +Keep heavy Cargo work serialized and caches intact. Freeze source edits during +CLI identity measurements. Record exact executed commands and counts. Do not +raise caps, reduce workloads or rewrite evidence. Serde buffering requires +measured qualification, not assumptions based on unchanged Rust object layout. + +## Authorization + +The final maintainer example requires `bidders.NAME.implementation` and nested +`bidders.NAME.settings` containing owner fields. Implementation retains its +original schema-selection/binding role. Each bidder owns and validates its +subconfig separately. Continue the already requested PR within this scope. diff --git a/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md index 280281c..46ad30f 100644 --- a/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md +++ b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md @@ -1,5 +1,9 @@ # Mocktioneer runtime endpoint implementation plan +November 9 format update: the [flat settings plan](2026-11-09-flat-bidder-settings.md) +replaces the earlier operator wrapper syntax consistently for every bidder. +This plan retains the original endpoint behavior and verification record. + > Follow test-driven development and the repository's verification ownership. > The user approved the runtime endpoint contract in the November 9 conversation. diff --git a/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md b/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md index e1e75d5..74df3c7 100644 --- a/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md +++ b/docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md @@ -1,152 +1,103 @@ -# Flat bidder settings design +# Bidder implementation and separate settings subconfig ## Requested contract -The maintainer requested the following operator format for every bidder: +The maintainer selected this exact structure for every bidder: ```toml [bidders.mocktioneer] enabled = true +implementation = "mocktioneer" [bidders.mocktioneer.settings] endpoint = "https://mock-bidder.example.test/openrtb2/auction" ``` -Remove the operator-facing `implementation` discriminator and `values` wrapper -consistently from TOML, JSON, typed CLI push/load, SDK extraction, documentation -and current test inputs. Common bidder controls remain beside `settings`. -Fields, types and semantic validators remain bidder-owned. The same generated -path supports Microsoft, The Trade Desk, Rubicon, Mocktioneer and newly discovered -settings declarations without a handwritten implementation list. +`implementation` sits on the bidder entry beside common controls. The separate +`.settings` subconfig contains only bidder-owned fields. Microsoft, The Trade +Desk, Rubicon, Mocktioneer and future discovered owners use this same structure. +Each owner retains its concrete settings type, allowed fields and validator. + +The selector keeps its original schema-selection and binding role. It neither +registers nor activates an implementation. Omit both selector and settings for +an entry containing only common overrides. If either is present, require both; +`settings = {}` is valid only for an owner whose optional fields permit it. +Unknown selectors, conflicting operator identities, unknown fields and malformed +present settings must fail closed rather than silently becoming absent settings. This extends #124 and the Mocktioneer runtime configuration work in PR #160. -Historical evidence and source captures retain their original bytes. This is a -pre-release authoring-format change within the current application schema; -existing tagged documents must be republished in the new form. Do not silently -maintain two accepted operator formats. - -## Constraints established by the current implementation - -`BidderOverrides` already provides the `.settings` boundary. The -wrapper is imposed by `bidder_settings_codegen`, not by the provider adapters. -The current internal tag also pins settings to an implementation. Removing it -must preserve that protection: request aliases can override host mappings and -even shadow a compiled public name. The endpoint/credentials selected for one -implementation must never be reinterpreted as another implementation's settings. - -Operator settings may name a future request-only alias. Its binding is unknown -at publication, so simply looking up the outer table name during deserialization -is insufficient. Empty settings, omission/null optional fields, independent -requested/base fallback and disabled/shadowed value validation must remain safe. - -## Alternatives - -1. **Flat generated scalar storage with borrowed typed views (recommended).** - Decode a bounded, redacted object, expose owner-specific borrowed fields, - resolve the implementation from the registry/host alias during admission, - and pin the same operator binding in the request-owned projection. This - supports shared field names across known implementations without guessing. -2. **Normalize flat input into the existing tagged enum.** This preserves owner - APIs but needs contextual staging at every parser boundary and a deferred - representation for request-only aliases. Avoid a second full config tree or - an allocation of cloned strings merely to recover the removed wire tag. -3. **Infer an enum variant from fields alone.** Simple for today's disjoint field - names, but ambiguous schemas and empty objects make this unsuitable as the - general bidder configuration contract. - -## Proposed data flow - -Generate an opaque flat `BidderSettings` object from discovered declarations. -Its one owned representation is a private `Vec` with a generated -closed key enum, string/i64/null scalar variants and an optional internal -`&'static str` implementation pin. Sort by generated key for stable output. -Serialization writes the fields directly as a map and omits the internal pin. -Only the existing closed scalar kinds are admitted: optional strings, signed -integers and secret references. Reject unknown keys, duplicate keys, arrays, -objects, booleans, floats, coercions and overflow, using fixed diagnostics. -Omitted and null optional values retain fieldwise absence; present empty strings -retain the owner's existing semantics. Keep the key of an explicit null during -schema checks and serialization: a null field must not become an absent field -and change which schemas match after a round trip. Empty means zero keys. -Redact all values in Debug/errors. - -Generate owner-specific `Settings<'a>` as thin `Copy` wrappers borrowing the -opaque object, with typed getter methods (`endpoint()`, `hb_source()`, etc.). -`as_owner()` returns `Option>`; `resolve` consumes two optional -Copy views and returns the existing fieldwise fallback view. No temporary typed -reference, `PhantomData`, per-owner string copy or dependency is needed. -Shared admission and request `set_settings` must first perform fallible checks -of both supplied entries' pins, allowed fields, scalar types, secret references -and limits. An accessor mismatch must never be treated as absent settings. - -For a compiled bidder or effective configured alias, admission resolves the -implementation through the existing independently compiled bindings. Validate -both supplied entries against that owner before invoking its semantic callback. -Independently resolve the operator owner of each supplied row, including a -fallback row. Host aliases do not chain: with `x -> alpha` and `alpha -> beta`, -the `alpha.settings` row is beta-owned and cannot supply alpha's settings to x, -even if both schemas declare the same fields. Finish the binding/schema pass -over the entire identity union before invoking any owner callback. -This permits two implementations to declare the same field name and still -select their own schema and rules. - -For a future request-only identity, nonempty settings must identify exactly one -compatible linked settings-owning implementation. Reject no-match and ambiguous -cases before activation; declaring the alias in operator configuration supplies -the missing identity in an ambiguous case. Empty settings carry no values and -may remain unbound until the requested identity is resolved. A known owner with -no settings declaration rejects supplied settings, including `{}`. Never select -the first of several matching schemas or take an implementation from request data. - -At request projection, independently recover the operator binding using the -effective host aliases and the host-supplied compiled bindings. Extend -`RuntimeBidderSettings::project` with those two trusted inputs; derive each row's -owner without consulting the request alias map (that map only selects which -rows to retain). Preserve an existing pin and refuse conflicting rebinding. -Store that -binding only in the existing request-owned settings object; omit it from wire -serialization. A request alias selecting a different implementation must fail -at settings binding before credentials, endpoint selection or body construction, -even when the two implementations share field names. Programmatic callers bind -an unbound object through their existing explicit trusted implementation input. - -## Resource and compatibility effects - -One bounded settings object owns scalar values; views borrow those values. -Projection makes only the copies already required by its existing lifetime -contract and does not copy unrelated settings or the whole policy. Charge and -measure any changed object/map layouts through their existing owners. Keep all -caps, ceilings, workloads and immutable evidence intact. The known construction -layout regression in PR #160 must not recur. - -Settings-free bidders, provider storage/envelopes, bidder parameters carried by -auction requests, privacy, alias precedence and credential ownership retain -their existing contracts. Configuration examples store literal URLs, not -Markdown link syntax. - -## Acceptance - -- The maintainer's exact table layout parses, serializes without the removed - wrappers, passes typed CLI validation/push dry-run and reaches serving code. -- Exercise all four existing owners with independent expected values; adding a - synthetic owner requires only its manifest and ordinary owner-local code. -- Keep typed rejection, absence/null/zero/empty semantics, per-field alias/base - fallback, credential redaction and validation of disabled/shadowed settings. -- Exercise compiled aliases, configured aliases, request-only aliases, empty - objects and ambiguous/shared field declarations with capable synthetic owners. -- Exercise cross-shadowed alias fallback, mixed null/shared-field round trips, - shared names with different scalar kinds and settings-free `{}` rejection. -- A request that retargets an operator-configured name cannot change its pinned - implementation, including shared field names; refusal precedes credentials - and body construction without mutating input or affecting valid siblings. -- Existing allocation/funding, exact native construction, CLI resource and guest - checks pass without changed limits or rewritten frozen evidence. -- Update active readers, writers and examples together; preserve historical - captures. Verify actual dispatched test counts and inspect CI independently. - -## Review - -An independent specification review identified the per-row fallback binding, -explicit-null identity, projection inputs and view-gating requirements above. -They are incorporated before implementation; no callback may observe an -unchecked row and no request alias may choose an operator settings pin. +It is a pre-release authoring-format change in the current application schema. +Operators must republish old wrapped documents after upgrading. Historical +evidence and source captures retain their original bytes. + +## Implementation + +Keep the original generated `BidderSettings(ImplementationSettings)` enum, +owner `Settings` structs, borrowed accessors, fieldwise fallback and semantic +validators. Its adjacent Serde tag becomes `implementation` with content named +`settings`, replacing `values`. + +The shared generic `BidderOverrides` serializes that typed envelope +as sibling fields at the bidder entry through `flatten`. Its Rust settings +field, ownership and validation APIs do not change. Deserialization uses a +private strict adapter instead of flattened Option decoding, which can suppress +errors. The adapter calls `deserialize_struct` with exactly `implementation` +and `settings`, peeks/replays one closed static key, and delegates the claimed +map to the existing typed enum. It returns None only when neither key exists; +all present-object errors propagate. The outer strict model rejects unclaimed +row fields. No JSON value tree, settings clone, inferred owner, new public API +or dependency is introduced. + +The outer generated object guard and owner-local `deny_unknown_fields` remain. +Shared code enforces common bounds and implementation binding. Bidder owners +retain scalar types, credential-reference rules, requiredness and endpoint +policies. Existing admission validates both requested and fallback settings +before semantic callbacks; request aliases cannot reinterpret another owner's +typed settings. An owner field named `implementation` is in a different nested +namespace and does not require a new declaration restriction. + +Update active readers, writers, tests and examples together. Reject the old +`.settings.implementation` / `.settings.values` operator shape. Historical +capture readers strictly check their recorded envelope/owner and convert only +at the fixture boundary; production does not accept a legacy format. + +## Alternatives considered + +- Required selector at the bidder entry with separate typed settings is the + selected layout. It matches the maintainer's final example and preserves + independent owner schemas and validation. +- Putting the selector inside `.settings` does not match that example. +- Optional field-based inference changes the discriminator's behavior and + introduces ambiguity. A flattened Option without the strict adapter can + swallow malformed supplied settings and is unsuitable. + +## Verification and resource acceptance + +Serde flattening buffers envelope fields before owner decoding. An unchanged +Rust layout does not establish equivalent decoding peaks. Run existing config +peak/lifecycle, SDK catalog growth, CLI resource, native construction and guest +controls without changing limits, workload sizes or immutable evidence. +Serialize heavy Cargo jobs and freeze source edits during identity checks. + +Acceptance requires: + +- Exact requested TOML and equivalent JSON round trips for all four owners, + actual typed CLI validation/push dry-run and local Fastly/Viceroy execution. +- Omitted pair versus partial pair, null/wrong-type/unknown selector, malformed + settings, duplicate selector/settings/common fields and unknown row fields. +- Selector/settings in either order, with common controls between them. +- Missing/unknown owner fields, scalar coercion, nonobject input, old wrapped + format rejection, independent semantic validators and redacted diagnostics. +- Existing disabled/shadowed, alias, per-field fallback, request retargeting, + credential and side-effect ordering contracts. +- Original resource ceilings and workloads, independent implementation review, + format/import/raw-fixture gates and inspection of the new CI run separately. + +## Review and authorization + +The maintainer's final exact table places `implementation` beside `enabled`, +with bidder-owned fields under the separate `.settings` table. This supersedes +earlier selector-placement proposals. Independent review checked local Serde +behavior: `deserialize_with` bypasses Option error suppression, +`deserialize_struct` consumes only the two envelope keys, and the outer strict +model rejects leftovers. Verify these properties with executable boundary tests. diff --git a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md index f052572..3fa29f6 100644 --- a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md +++ b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md @@ -1,5 +1,10 @@ # Configure Mocktioneer endpoints through runtime policy +November 9 format update: the maintainer subsequently selected fields directly +under `.settings` for every bidder. The [flat settings design](2026-11-09-flat-bidder-settings-design.md) +supersedes the wrapper syntax recorded below; endpoint validation and authority +requirements continue to apply. + Status: operator contract approved in the November 9 conversation; implementation and target verification belong to the accompanying PR. Related to #124. From 40fc2155a15de237c6bf1602dda3180ca201a40d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:35:52 -0800 Subject: [PATCH 05/14] Fix endpoint admission and settings verification --- Cargo.lock | 1 + README.md | 6 ++- .../src/endpoint_validator.rs | 4 ++ crates/auctioneer-config/Cargo.toml | 2 + crates/auctioneer-config/src/url_safety.rs | 30 ++++++++++++- crates/auctioneer-config/tests/root_shape.rs | 12 +++++- crates/auctioneer-core/src/outbound/mod.rs | 42 +++++++++++++++++++ .../native_bidder_onboarding/fixtures.rs | 11 +++-- .../plans/2026-11-09-flat-bidder-settings.md | 3 +- ...-09-mocktioneer-runtime-endpoint-design.md | 13 +++++- 10 files changed, 112 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a519c60..6109fcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,7 @@ dependencies = [ "auctioneer-openrtb-catalogue", "auctioneer-runtime", "hex", + "http", "ipnetwork", "percent-encoding", "proc-macro2", diff --git a/README.md b/README.md index 93c69eb..2c1bbc1 100644 --- a/README.md +++ b/README.md @@ -254,8 +254,10 @@ the binary. Use a literal absolute URL of at most 2048 UTF-8 bytes. Public destinations require HTTPS; the development feature also allows HTTP to exact loopback literals. Private/reserved addresses, localhost-alias services, credentials, -fragments and templates are rejected. Omitted/null settings inherit the base -bidder setting, then the compiled default; an empty endpoint is invalid. +fragments and templates are rejected. Use ASCII host names (punycode for +internationalized names) and percent-encode characters the HTTP transport +cannot represent. Omitted/null endpoint values inherit the base bidder setting, +then the compiled default; an empty endpoint is invalid. Impression parameters cannot override the selected destination. Validate and push the complete policy to local Fastly/Viceroy from the workspace diff --git a/crates/auctioneer-bidder/src/endpoint_validator.rs b/crates/auctioneer-bidder/src/endpoint_validator.rs index d99a648..66b1fe4 100644 --- a/crates/auctioneer-bidder/src/endpoint_validator.rs +++ b/crates/auctioneer-bidder/src/endpoint_validator.rs @@ -92,6 +92,10 @@ mod tests { "https://private-canary@configured.example.test/bid", "https://127.0.0.2/private-canary", "http://configured.example.test/private-canary", + "https://%62idder.example/private-canary", + "https://b\u{fc}cher.example/private-canary", + "https://configured.example.test/private-canary Result<(), String> /// /// # Errors /// Returns only static reason categories for malformed, templated, credentialed, -/// fragmented, or unsafe destinations; rejected input is never included. +/// fragmented, transport-incompatible, or unsafe destinations; rejected input +/// is never included. pub fn validate_configured_bidder_endpoint( raw: &str, allow_insecure: bool, @@ -201,6 +202,10 @@ pub fn validate_configured_bidder_endpoint( } let parsed = url::Url::parse(raw).map_err(|_error| "endpoint URL is malformed")?; validate_url_spelling(raw, &parsed).map_err(|_error| "endpoint URL spelling is invalid")?; + // The borrowed literal reaches the transport before its URL normalization. + // Require that exact spelling to fit the same HTTP URI type used there. + raw.parse::() + .map_err(|_error| "endpoint URL cannot be represented by the transport")?; if parsed.host_str().is_none() { return Err("endpoint authority is missing"); } @@ -725,6 +730,23 @@ pub fn endpoint_host(endpoint: &str) -> Option { mod tests { use super::{is_exact_loopback_literal, is_localhost_alias_host, is_loopback_range}; + #[test] + fn configured_endpoint_rejects_spellings_the_transport_cannot_represent() { + for endpoint in [ + "https://%62idder.example/auction", + "https://b\u{fc}cher.example/auction", + "https://bidder.example/a; fn fixture() -> Value { @@ -21,7 +27,7 @@ mod tests { "host": {"status_response": "", "resource_limits": {"default_tmax_ms": 500_u64}}, "account_defaults": {"disabled": false, "targeting_prefix": "hb"}, "accounts": {"fake_publisher": {"debug_allow": true}}, - "bidders": {"fake_bidder": {"settings": {"implementation": "fake"}, "enabled": false}}, + "bidders": {"fake_bidder": {"implementation": "fake", "settings": {}, "enabled": false}}, "features": {"gzip_outbound": false}, "floor_currency": {"expires_at": 2000_i64, "usd_per_unit": {"EUR": 1.25_f64}} }) @@ -44,6 +50,10 @@ mod tests { canonical["accounts"]["fake_publisher"]["debug_allow"], json!(true) ); + assert_eq!( + canonical["bidders"]["fake_bidder"]["implementation"], + json!("fake") + ); assert_eq!( canonical["bidders"]["fake_bidder"]["settings"], input["bidders"]["fake_bidder"]["settings"] diff --git a/crates/auctioneer-core/src/outbound/mod.rs b/crates/auctioneer-core/src/outbound/mod.rs index b8b2ce6..75ead74 100644 --- a/crates/auctioneer-core/src/outbound/mod.rs +++ b/crates/auctioneer-core/src/outbound/mod.rs @@ -872,6 +872,48 @@ mod tests { } } + #[test] + fn admitted_endpoint_spellings_reach_the_transport() { + for (literal, expected) in [ + ( + "https://bidder.example/auction", + "https://bidder.example/auction", + ), + ( + "https://xn--bcher-kva.example/auction", + "https://xn--bcher-kva.example/auction", + ), + ( + "https://bidder.example/a%3Cb", + "https://bidder.example/a%3Cb", + ), + ( + "https://bidder.example/a%60b", + "https://bidder.example/a%60b", + ), + ( + "https://bidder.example/caf\u{e9}", + "https://bidder.example/caf%C3%A9", + ), + ( + "HTTPS://BIDDER.EXAMPLE:443/auction?q=a%2Fb", + "https://bidder.example/auction?q=a%2Fb", + ), + ] { + let endpoint = auctioneer_bidder::ConfiguredEndpoint::new(literal).unwrap(); + let converted = to_outbound( + BoundedOutboundRequest { + request: request(endpoint.as_str()), + policy: test_policy(), + deadline: composed(), + }, + &limits(), + ) + .expect("admitted literal must survive transport preflight"); + assert_eq!(converted.uri().to_string(), expected); + } + } + /// Every converted request carries `Bypass`, never the /// `PlatformDefault` an unset policy would inherit: a cached bid /// response is both a stale-bid correctness bug and a cross-auction diff --git a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs index 896a7d3..c451c11 100644 --- a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs +++ b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs @@ -298,17 +298,20 @@ fn flow_from_member( { // Preserve the historical capture; its owner and fields move to the // bidder row and settings subconfig only when creating runtime input. - let recorded = recorded.as_object().expect("recorded settings object"); + let recorded_fields = recorded.as_object().expect("recorded settings object"); assert_eq!( - recorded.keys().map(String::as_str).collect::>(), + recorded_fields + .keys() + .map(String::as_str) + .collect::>(), BTreeSet::from(["implementation", "values"]), ); assert_eq!( - recorded["implementation"].as_str(), + recorded_fields["implementation"].as_str(), Some(member.implementation.as_str()), "recorded settings belong to their fixture owner", ); - assert!(recorded["values"].is_object()); + assert!(recorded_fields["values"].is_object()); } assert!(!case.outbound.is_empty()); let ids: BTreeMap<_, _> = case.request["imp"] diff --git a/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md index 3fdbd48..683c69c 100644 --- a/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md +++ b/docs/superpowers/plans/2026-11-09-flat-bidder-settings.md @@ -9,7 +9,8 @@ separate, strictly typed `.settings` subconfig for every owner. its content key to `settings`. Flatten that envelope at the shared bidder-entry boundary with a strict private deserialization adapter that propagates errors. -**Tech stack:** Existing Rust, Serde, quote/syn and test owners; no new dependencies. +**Tech stack:** Existing Rust, Serde, quote/syn and test owners. The endpoint +self-review correction reuses the workspace HTTP URI parser in `config-model`. **Design:** `docs/superpowers/specs/2026-11-09-flat-bidder-settings-design.md`. diff --git a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md index 3fa29f6..b4f8c0e 100644 --- a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md +++ b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md @@ -58,6 +58,12 @@ backslashes, credentials/userinfo (including an empty userinfo marker), fragments, and any template placeholder. Preserve a supplied path, query and explicit port; they are operator configuration, not request substitutions. +The exact borrowed spelling must also parse as the workspace's `http::Uri`, +which both outbound paths require before SDK normalization. Reject incompatible +spellings during configuration admission: use punycode for internationalized +host names and percent-encode path characters the HTTP parser cannot represent. +Literal Unicode paths that the HTTP parser accepts remain valid. + Reuse the shared URL-safety implementation. HTTPS public destinations are accepted. HTTP is allowed only for the existing exact local literals `127.0.0.1`, `[::1]` and `localhost` under `insecure-bidders`. Private, reserved @@ -107,8 +113,11 @@ trusted selection to construction and sink enforcement. Adapters do not parse or reinterpret this policy. Borrow endpoint strings from the already bounded coherent operator snapshot. -Do not clone bidder metadata, allocate a second config tree, retain a new URL -cache, or add dependencies. URL parsing remains bounded by the endpoint limit. +Do not clone bidder metadata, allocate a second config tree, or retain a new URL +cache. The self-review correction reuses the existing workspace `http` +dependency behind `config-model` to align admission with transport; it adds no +new dependency version or native tooling. URL parsing remains bounded by the +endpoint limit, and the URI compatibility probe is dropped before continuing. Existing config byte/node/depth, metadata, call, body and deadline limits remain unchanged. Adding a generated settings variant changes type/layout artifacts; run the existing relevant layout/allocation owners and report any real change From 5fb91b57d61032777600bb97d529fb190737fd9c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:06:25 -0800 Subject: [PATCH 06/14] Explain configuration validation failures --- README.md | 3 + .../qualification/harness/native/snapshots.rs | 2 +- crates/auctioneer-core/src/config/sdk.rs | 139 ++++++++++++++++-- .../auctioneer-core/src/config/validation.rs | 26 +++- ...-09-mocktioneer-runtime-endpoint-design.md | 5 + 5 files changed, 156 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2c1bbc1..1112b5e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,9 @@ auctioneer-cli config push --adapter fastly --local --app-config auctioneer.fast ``` Use the same Mocktioneer-enabled feature set for the CLI and the serving guest. +If a configured implementation is missing from the build, CLI errors and runtime +operator logs identify its compiled name and required Cargo feature. These +diagnostics omit configured alias names, endpoint values and credentials. Endpoint changes take effect through the existing config publication lifecycle; they require no endpoint-specific rebuild. The other bidders' compiled endpoint and host-allowlist rules remain unchanged. diff --git a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs index 31ef021..87dce92 100644 --- a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs +++ b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs @@ -739,7 +739,7 @@ mod tests { assert_eq!( error, json!({"runtime": [{ - "code": "invalid_effective_config", "message": null, "params": {} + "code": "invalid_effective_config", "message": "operator policy structure invalid", "params": {} }]}) ); } diff --git a/crates/auctioneer-core/src/config/sdk.rs b/crates/auctioneer-core/src/config/sdk.rs index 8d7ffa8..01c0cf0 100644 --- a/crates/auctioneer-core/src/config/sdk.rs +++ b/crates/auctioneer-core/src/config/sdk.rs @@ -1,8 +1,12 @@ //! Local SDK traits for the shared model without repeating its fields. -use super::{AuctioneerAppConfig, EffectiveConfigError, validate_effective_defaults}; +use super::AuctioneerAppConfig; +use super::validation::{ + BidderConfigurationError, CompiledPolicyError, PolicyValidationError, validate_compiled_policy, +}; use edgezero_core::app_config::{AppConfigMeta, AppConfigRoot, SecretField}; use serde::{Deserialize, Deserializer, Serialize}; +use std::borrow::Cow; use std::ops::{Deref, DerefMut}; use validator::{Validate, ValidateArgs, ValidationError, ValidationErrors}; @@ -41,20 +45,70 @@ impl DerefMut for SdkAuctioneerAppConfig { impl Validate for SdkAuctioneerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { - validate_effective_defaults(&self.0).map_err(|error| validation_error(&error)) + validate_compiled_policy( + &self.0, + auctioneer_config_data::host_default(), + auctioneer_config_data::account_default(), + ) + .map_err(|error| { + let errors = validation_error(&error, &self.0); + // The SDK extractor retains only the field path. Report the safe + // application-owned reason here, before that redaction, for operators. + log::error!("operator configuration validation failed: {errors}"); + errors + }) } } -fn validation_error(error: &EffectiveConfigError) -> ValidationErrors { - let code = match error { - EffectiveConfigError::Registry(_) => "registry_build_failed", - EffectiveConfigError::Malformed(_) => "invalid_effective_config", +fn validation_error(error: &CompiledPolicyError, config: &AuctioneerAppConfig) -> ValidationErrors { + let (code, message) = match error { + CompiledPolicyError::Registry(_) => ( + "registry_build_failed", + Cow::Borrowed("compiled bidder registry is invalid; rebuild the CLI and serving guest"), + ), + CompiledPolicyError::Policy(reason) => ( + "invalid_effective_config", + policy_diagnostic(*reason, config), + ), }; let mut errors = ValidationErrors::new(); - errors.add("runtime", ValidationError::new(code)); + errors.add("runtime", ValidationError::new(code).with_message(message)); errors } +fn policy_diagnostic( + reason: PolicyValidationError, + config: &AuctioneerAppConfig, +) -> Cow<'static, str> { + if matches!( + reason, + PolicyValidationError::Bidder(BidderConfigurationError::SettingsBinding) + ) { + for settings in config + .bidders + .values() + .filter_map(|entry| entry.settings.as_ref()) + { + let declaration = auctioneer_bidder::bidder_declarations() + .iter() + .find(|row| row.key == settings.implementation_key()); + if let Some(missing) = declaration.filter(|row| !row.has_settings) + && let Some(feature) = missing.required_feature + { + // Both identifiers come from compiled declarations, never the + // configured alias name, endpoint, credential or rejected value. + return Cow::Owned(format!( + "bidder implementation '{}' is unavailable in this build; rebuild both the CLI and serving guest with Cargo feature '{feature}'", + missing.key, + )); + } + } + } + // PolicyValidationError contains only fixed categories and reviewed static + // bidder field/reason identifiers. Never format a raw validator error here. + Cow::Owned(reason.to_string()) +} + impl ValidateArgs<'_> for SdkAuctioneerAppConfig { type Args = (); @@ -105,14 +159,19 @@ mod tests { #[test] fn sdk_validation_keeps_registry_and_operator_refusals_distinct_without_input_values() { - let registry = super::validation_error(&super::EffectiveConfigError::Registry( - auctioneer_bidder::RegistryBuildError::MissingBinding { - bidder: "fake_secret_canary", - }, - )); - let malformed = super::validation_error(&super::EffectiveConfigError::Malformed( - "fake_secret_canary".to_owned(), - )); + let config = AuctioneerAppConfig::default(); + let registry = super::validation_error( + &super::CompiledPolicyError::Registry( + auctioneer_bidder::RegistryBuildError::MissingBinding { + bidder: "fake_secret_canary", + }, + ), + &config, + ); + let malformed = super::validation_error( + &super::CompiledPolicyError::Policy(super::PolicyValidationError::UserSync), + &config, + ); assert_eq!( registry.field_errors()["runtime"][0].code, "registry_build_failed" @@ -154,7 +213,7 @@ mod tests { let error = SdkAuctioneerAppConfig(model).validate().unwrap_err(); assert_eq!( serde_json::to_value(&error).unwrap(), - json!({"runtime": [{"code": "invalid_effective_config", "message": null, "params": {}}]}), + json!({"runtime": [{"code": "invalid_effective_config", "message": "operator policy user sync invalid", "params": {}}]}), "the real SDK boundary must reject {field}", ); for diagnostic in [error.to_string(), format!("{error:?}")] { @@ -163,6 +222,54 @@ mod tests { } } + #[test] + fn sdk_validation_explains_the_safe_policy_refusal() { + let config: SdkAuctioneerAppConfig = serde_json::from_value(json!({ + "schema_version": 1_u8, + "host": {"user_sync": {"external_url": "fake_private_url_canary"}}, + })) + .unwrap(); + let errors = config.validate().unwrap_err(); + assert!( + errors + .to_string() + .contains("operator policy user sync invalid") + ); + assert!(!format!("{errors:?}").contains("fake_private_url_canary")); + } + + #[cfg(not(feature = "insecure-bidders"))] + #[test] + fn sdk_validation_explains_missing_owner_features_without_configured_names() { + let mut checked = 0_usize; + for declaration in auctioneer_bidder::bidder_declarations() { + let Some(feature) = declaration.required_feature else { + continue; + }; + if declaration.has_settings { + continue; + } + let Ok(config) = serde_json::from_value::(json!({ + "schema_version": 1_u8, + "bidders": {"fake_private_alias_canary": { + "enabled": false, + "implementation": declaration.key, + "settings": {}, + }}, + })) else { + continue; + }; + let errors = config.validate().unwrap_err(); + let message = errors.to_string(); + assert!(message.contains(declaration.key)); + assert!(message.contains(feature)); + assert!(message.contains("CLI and serving guest")); + assert!(!format!("{errors:?}").contains("fake_private_alias_canary")); + checked += 1; + } + assert!(checked > 0, "exercise disabled typed settings owners"); + } + #[test] fn sdk_wrapper_keeps_model_layout_and_existing_empty_secret_metadata() { assert_eq!( diff --git a/crates/auctioneer-core/src/config/validation.rs b/crates/auctioneer-core/src/config/validation.rs index a6f3f6b..779c259 100644 --- a/crates/auctioneer-core/src/config/validation.rs +++ b/crates/auctioneer-core/src/config/validation.rs @@ -50,6 +50,13 @@ pub enum PolicyValidationError { Bidder(#[from] BidderConfigurationError), } +/// Preserve reviewed policy categories until the SDK builds safe diagnostics. +#[derive(Debug)] +pub(super) enum CompiledPolicyError { + Registry(auctioneer_bidder::RegistryBuildError), + Policy(PolicyValidationError), +} + /// Validate a complete policy without installing it or reading ambient state. /// Callers must first bound and fund the source, merged views, namespace and /// validation temporaries. Success is not snapshot or startup admission. @@ -80,9 +87,24 @@ pub fn validate_compiled_operator_policy( product_host: &HostConfig, product_account: &AccountConfig, ) -> Result<(), super::EffectiveConfigError> { - let catalog = ValidatedRegistryPlan::compiled()?; + validate_compiled_policy(policy, product_host, product_account).map_err(|error| match error { + CompiledPolicyError::Registry(registry_error) => { + super::EffectiveConfigError::Registry(registry_error) + } + CompiledPolicyError::Policy(policy_error) => { + super::EffectiveConfigError::Malformed(policy_error.to_string()) + } + }) +} + +pub(super) fn validate_compiled_policy( + policy: &super::policy::OperatorPolicy, + product_host: &HostConfig, + product_account: &AccountConfig, +) -> Result<(), CompiledPolicyError> { + let catalog = ValidatedRegistryPlan::compiled().map_err(CompiledPolicyError::Registry)?; validate_effective_operator_policy(policy, product_host, product_account, &catalog) - .map_err(|error| super::EffectiveConfigError::Malformed(error.to_string())) + .map_err(CompiledPolicyError::Policy) } /// Validate structure, merged semantics, settings bindings and bidder callbacks. diff --git a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md index b4f8c0e..55c9414 100644 --- a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md +++ b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md @@ -77,6 +77,11 @@ localhost spellings do not become approved literals through URL normalization. All configuration and request-time refusal messages use fixed field/reason categories. Never include a rejected URL, credentials, host or parser message. +Missing-feature diagnostics may name the implementation and feature from +compiled declarations. Auctioneer retains typed policy categories at its SDK +boundary and logs their safe explanations before SDK extraction redacts them. +Public HTTP config errors keep their existing coarse classification. Upstream +diagnostic propagation is tracked separately in IABTechLab/edgezero#457. ## Request construction and enforcing sink From 9b3316d425cc13a99c8a168543026d2a05d1509b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:22:06 -0800 Subject: [PATCH 07/14] Update generated settings diagnostic assertions --- .../support/generated_settings_contract.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs b/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs index 9c58f08..c23d381 100644 --- a/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs +++ b/crates/auctioneer-bidder/tests/support/generated_settings_contract.rs @@ -254,7 +254,7 @@ mod sdk_validation_allocation { } else { assert_eq!( serde_json::to_value(result.unwrap_err()).unwrap(), - json!({"runtime": [{"code": "invalid_effective_config", "message": null, "params": {}}]}) + json!({"runtime": [{"code": "invalid_effective_config", "message": "operator policy structure invalid", "params": {}}]}) ); } assert_eq!(factory_calls(), factories_before); @@ -1266,6 +1266,7 @@ mod compiled_sdk { document: &Value, expected: Result<(), PolicyValidationError>, observed: &[Observation], + diagnostic: Option<&str>, ) { let before = auctioneer_bidder::fake_synthetic::factory_calls(); let _ = take(); @@ -1294,10 +1295,10 @@ mod compiled_sdk { toml::from_str::(&toml::to_string(document).unwrap()).unwrap(), ] { match (expected, sdk.validate()) { - (Ok(()), Ok(())) => {} + (Ok(()), Ok(())) => assert!(diagnostic.is_none()), (Err(_), Err(errors)) => assert_eq!( serde_json::to_value(errors).unwrap(), - json!({"runtime": [{"code": "invalid_effective_config", "message": null, "params": {}}]}) + json!({"runtime": [{"code": "invalid_effective_config", "message": diagnostic.expect("refusal has an independent expected diagnostic"), "params": {}}]}) ), (expected, actual) => { panic!("SDK/typed parity: expected {expected:?}, got {actual:?}") @@ -1329,9 +1330,9 @@ mod compiled_sdk { } assert_eq!(auctioneer_bidder::fake_synthetic::factory_calls(), before); let mut input = document(); - assert_parity(&input, Ok(()), &successful_dispatch()); + assert_parity(&input, Ok(()), &successful_dispatch(), None); input["floor_currency"] = json!({"expires_at": 1, "usd_per_unit": {"QAA": 1.25}}); - assert_parity(&input, Ok(()), &successful_dispatch()); + assert_parity(&input, Ok(()), &successful_dispatch(), None); } #[test] @@ -1354,6 +1355,7 @@ mod compiled_sdk { BidderConfigurationError::SettingsBinding, )), &[], + Some("bidder configuration settings binding invalid"), ); cases += 1; } @@ -1396,6 +1398,7 @@ mod compiled_sdk { Some(secondary), ), ], + Some(&format!("bidder configuration invalid: {field} (invalid)")), ); cases += 1; } @@ -1405,7 +1408,7 @@ mod compiled_sdk { #[test] fn sdk_and_typed_paths_preserve_compiled_ancestry_and_per_identity_requiredness() { - assert_parity(&json!({"schema_version": 1}), Ok(()), &[]); + assert_parity(&json!({"schema_version": 1}), Ok(()), &[], None); let input = json!({"schema_version": 1, "bidders": { "fake_wire_base": {"enabled": true, "implementation": "fake_integer_probe", "settings": {"primary_number": 7, "secondary_number": 11}}, "fake_wire_leaf": {"implementation": "fake_integer_probe", "settings": {"primary_number": 0}} @@ -1417,6 +1420,7 @@ mod compiled_sdk { integer(Some("fake_wire_base"), true, Some(7), Some(11)), integer(Some("fake_wire_leaf"), true, Some(0), None), ], + Some("bidder configuration invalid: secondary_number (required)"), ); let disabled_parent = json!({"schema_version": 1, "host": {"aliases": {"fake_host_alias": "fake_wire_leaf"}}, @@ -1431,6 +1435,7 @@ mod compiled_sdk { integer(Some("fake_wire_leaf"), false, None, None), integer(Some("fake_wire_base"), true, Some(7), Some(11)), ], + None, ); } } From ee6508e60108d853143e1bbe51fb563ca34411c4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:10:46 -0800 Subject: [PATCH 08/14] Make Mocktioneer available with secure defaults --- README.md | 28 +-- auctioneer.toml | 2 +- crates/auctioneer-adapter-axum/src/lib.rs | 2 +- .../tests/privacy_wire.rs | 24 ++- .../tests/resource_profile.rs | 2 +- .../tests/usersync_transport.rs | 40 ++++- .../src/bidders/mocktioneer/bidder.toml | 7 +- .../src/bidders/mocktioneer/config.rs | 138 +++++++++++---- .../bidders/mocktioneer/fixtures/factory.json | 2 +- .../src/bidders/mocktioneer/mod.rs | 28 +-- .../auctioneer-bidder/src/endpoint_builder.rs | 34 +++- .../auctioneer-bidder/src/registry/catalog.rs | 2 +- crates/auctioneer-bidder/src/request_info.rs | 6 +- .../src/test_support/fake_bidder/controls.rs | 7 +- .../src/test_support/serving_catalog.rs | 45 ++++- .../auctioneer-bidder/tests/catalog_alloc.rs | 2 +- .../tests/catalog_publication.rs | 15 +- .../tests/endpoint_expansion_bound.rs | 22 +-- .../tests/fake_registry/mod.rs | 2 +- .../feature_exports_source_conformance.rs | 6 +- .../tests/native_schema_resources.rs | 10 +- .../auctioneer-bidder/tests/registry_join.rs | 8 +- .../tests/registry_source_conformance.rs | 26 +-- .../registry_workspace/registry_probe.rs | 6 +- .../tests/schema_source_conformance.rs | 12 +- .../support/generated_exchange_settings.rs | 2 +- .../qualification/harness/native/snapshots.rs | 107 ++++++++---- crates/auctioneer-config-data/Cargo.toml | 4 +- crates/auctioneer-config-data/build.rs | 43 +++-- .../tests/bidder_storage.rs | 2 +- .../tests/build_failure_harness.rs | 92 ++++++++++ .../auctioneer-config-data/tests/codegen.rs | 2 +- crates/auctioneer-config-data/tests/smoke.rs | 57 ++++-- crates/auctioneer-config/src/codegen/mod.rs | 19 +- .../auctioneer-config/src/codegen/storage.rs | 2 +- crates/auctioneer-config/src/types.rs | 4 +- crates/auctioneer-core/src/config/sdk.rs | 30 +--- crates/auctioneer-core/src/conformance/mod.rs | 2 +- .../auctioneer-core/src/handlers/auction.rs | 2 +- .../src/handlers/info_bidders.rs | 2 +- .../src/test_support/bidder_fixture.rs | 2 +- .../src/test_support/outbound_request.rs | 2 +- .../tests/auction_config_lifecycle.rs | 11 +- .../tests/auction_config_retention.rs | 5 +- .../tests/bidder_handler_conformance.rs | 4 +- .../impression_limit_on_the_auction_path.rs | 5 +- .../tests/native_bidder_onboarding/cases.rs | 92 +++++++++- .../native_bidder_onboarding/fixtures.rs | 55 ++++-- .../tests/node_budget_on_the_auction_path.rs | 5 +- .../auctioneer-core/tests/privacy_outbound.rs | 4 +- .../tests/privacy_wire_signal_400.rs | 5 +- .../tests/response_gdpr_applies.rs | 2 +- .../tests/response_mid_flight_timeout.rs | 8 +- .../tests/response_oversize_response.rs | 64 ++++--- .../tests/response_slot_classification.rs | 2 +- .../auctioneer-exchange/src/debug/capture.rs | 2 +- crates/auctioneer-exchange/src/limits.rs | 2 +- crates/auctioneer-exchange/src/run/mod.rs | 18 +- .../src/validate_endpoint.rs | 10 +- .../tests/auction_debug/main.rs | 4 +- .../tests/auction_support/mod.rs | 2 +- .../tests/bid_impression_correlation.rs | 2 +- .../tests/bidder_call_correlation.rs | 2 +- .../tests/bidder_response_node_budget.rs | 2 +- .../tests/bidder_services.rs | 2 +- .../tests/clone_bounds/main.rs | 2 +- .../tests/code12_attribution.rs | 2 +- .../tests/config_overlay.rs | 4 +- .../tests/construction_bound.rs | 2 +- .../tests/construction_deadline.rs | 6 +- .../tests/debug_allocation/mod.rs | 4 +- .../auctioneer-exchange/tests/determinism.rs | 2 +- .../tests/disclosure_policy_diff.rs | 5 +- .../tests/expired_tmax_diagnostics.rs | 2 +- .../tests/outbound_construction.rs | 4 +- .../tests/privacy_enforcement.rs | 2 +- .../tests/projection_scrub_bound.rs | 2 +- .../tests/request_provenance.rs | 2 +- .../tests/response_charge.rs | 9 +- .../tests/run_auction/main.rs | 162 ++++++++++-------- .../auctioneer-exchange/tests/sampling_nbr.rs | 2 +- .../tests/sparse_request_copies/main.rs | 6 +- .../tests/provider_allocation_conformance.rs | 4 +- .../tests/targeting_prefix/main.rs | 2 +- .../tests/validate_endpoint.rs | 20 +-- docs/architecture.md | 7 +- docs/bidders/schema-qualification.md | 7 + ...2026-11-09-mocktioneer-runtime-endpoint.md | 2 + .../2026-11-10-mocktioneer-secure-default.md | 69 ++++++++ ...-09-mocktioneer-runtime-endpoint-design.md | 2 + ...11-10-mocktioneer-secure-default-design.md | 42 +++++ docs/testing/resource-qualification.md | 4 +- docs/testing/usersync-browser.md | 3 +- 93 files changed, 1088 insertions(+), 450 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-10-mocktioneer-secure-default.md create mode 100644 docs/superpowers/specs/2026-11-10-mocktioneer-secure-default-design.md diff --git a/README.md b/README.md index 1112b5e..02c9949 100644 --- a/README.md +++ b/README.md @@ -105,16 +105,16 @@ cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings ``` -The `insecure-bidders` cargo feature on `auctioneer-config-data` enables -deterministic loopback test bidders: +The `insecure-bidders` Cargo feature permits development HTTP/loopback +transport. Mocktioneer itself is available in normal builds: ``` -cargo test --workspace --features auctioneer-config-data/insecure-bidders +cargo test --workspace --features insecure-bidders ``` Axum's default `cargo test -p auctioneer-adapter-axum --all-targets` also runs the -handler-to-socket privacy test. Its test-only dependency enables the loopback -bidder; normal production builds retain the secure defaults. The test sweeps +handler-to-socket privacy test. Its test-only dependency permits loopback +transport; the fixture explicitly enables and configures its bidder. The test sweeps CCPA and all three disclosure activities independently. ## Auction privacy configuration @@ -232,9 +232,8 @@ Run them from the workspace root. ### Configure a Mocktioneer destination -Mocktioneer is available only in builds with `insecure-bidders`. Its default -destination is `http://127.0.0.1:8787/openrtb2/auction`. To use another mock -deployment, add its endpoint to your runtime policy: +Mocktioneer is available in normal builds and is disabled by default. Enable +it and configure its HTTPS destination in your runtime policy: ```toml [bidders.mocktioneer] @@ -256,8 +255,9 @@ require HTTPS; the development feature also allows HTTP to exact loopback literals. Private/reserved addresses, localhost-alias services, credentials, fragments and templates are rejected. Use ASCII host names (punycode for internationalized names) and percent-encode characters the HTTP transport -cannot represent. Omitted/null endpoint values inherit the base bidder setting, -then the compiled default; an empty endpoint is invalid. +cannot represent. Omitted/null endpoint values inherit the base bidder setting. +An enabled Mocktioneer requires a resolved endpoint; it has no compiled default. +An empty endpoint is invalid. Impression parameters cannot override the selected destination. Validate and push the complete policy to local Fastly/Viceroy from the workspace @@ -268,8 +268,12 @@ auctioneer-cli config validate --app-config auctioneer.fastly-local.toml --no-en auctioneer-cli config push --adapter fastly --local --app-config auctioneer.fastly-local.toml --no-env ``` -Use the same Mocktioneer-enabled feature set for the CLI and the serving guest. -If a configured implementation is missing from the build, CLI errors and runtime +HTTPS needs no extra Cargo features in either the CLI or the serving guest. +For an explicitly configured local endpoint such as +`http://127.0.0.1:8787/openrtb2/auction`, build the CLI with +`--features auctioneer-core/insecure-bidders` and the serving adapter with +`--features insecure-bidders`. The feature never supplies a destination. +If another configured implementation is missing from the build, CLI errors and runtime operator logs identify its compiled name and required Cargo feature. These diagnostics omit configured alias names, endpoint values and credentials. Endpoint changes take effect through the existing config publication lifecycle; diff --git a/auctioneer.toml b/auctioneer.toml index 2988c91..0ea0177 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -294,7 +294,7 @@ returnallbidstatus_default = false # credentials_secret: a secret-store key, never the credential bundle itself. # external_url: publisher-facing URL used by the owning protocol. # region: "us-east", "us-west", "eu" or "apac" for the regional implementation. -# endpoint: literal operator destination for development-only Mocktioneer. +# endpoint: required operator destination for enabled Mocktioneer; HTTPS works in normal builds. # BEGIN BIDDER EXAMPLE # [bidders.fake_bidder] # enabled = true diff --git a/crates/auctioneer-adapter-axum/src/lib.rs b/crates/auctioneer-adapter-axum/src/lib.rs index 8879dc8..1576edc 100644 --- a/crates/auctioneer-adapter-axum/src/lib.rs +++ b/crates/auctioneer-adapter-axum/src/lib.rs @@ -10,7 +10,7 @@ use auctioneer_config_data::types::BidderInfo; pub fn build_features() -> Vec<&'static str> { let endpoint = "http://127.0.0.1:8999"; let info = BidderInfo { - endpoint: endpoint.to_owned(), + endpoint: Some(endpoint.to_owned()), ..BidderInfo::default() }; let mut features = if auctioneer_exchange::validate_endpoint(endpoint, &info).is_ok() { diff --git a/crates/auctioneer-adapter-axum/tests/privacy_wire.rs b/crates/auctioneer-adapter-axum/tests/privacy_wire.rs index b7bec55..3a19f02 100644 --- a/crates/auctioneer-adapter-axum/tests/privacy_wire.rs +++ b/crates/auctioneer-adapter-axum/tests/privacy_wire.rs @@ -37,6 +37,7 @@ mod tests { use tokio::time::{sleep, timeout}; const WAIT: Duration = Duration::from_secs(10); + const LOCAL_ENDPOINT: &str = "http://127.0.0.1:8787/openrtb2/auction"; const BIDDER_ALIAS: &str = "fake_privacy_wire"; const ID_PATHS: &[&str] = &[ "/user/id", @@ -177,16 +178,18 @@ mod tests { fixture: &Value, policy: WirePolicy, client: &HttpClient, - bidder: &str, + bidder: &serving_catalog::ServingBidder, ) -> Request { let config_json = json!({ "schema_version": 1_u8, "host": { - "aliases": {(BIDDER_ALIAS): bidder}, + "aliases": {(BIDDER_ALIAS): bidder.name()}, "gdpr": {"enabled": false}, "ccpa": {"enforce": policy.enforce_ccpa}, "lmt": {"enforce": false}, }, "features": {"gzip_outbound": false}, + "bidders": {(bidder.name()): {"enabled": true, "implementation": bidder.implementation, + "settings": {"endpoint": LOCAL_ENDPOINT}}}, "accounts": {"wire-account": {"privacy": { "ipv4": {"anon_keep_bits": 24_i32}, "ipv6": {"anon_keep_bits": 64_i32}, @@ -398,30 +401,26 @@ mod tests { } async fn privacy_wire_scenario() { - let local_bidders: Vec<_> = serving_catalog::default_active_publications() + let local_bidders: Vec<_> = serving_catalog::published() .into_iter() .filter(|row| { let info = row.info; !info.disabled - && !info.uses_https + && info.default_enabled == Some(false) + && info.endpoint.is_none() && info.endpoint_macros.is_empty() && info.endpoint_compression.is_none() && info.capabilities.site.contains(&MediaType::Banner) && row.owner_expected_request_body.as_ref() == Some(&row.owner_request) - && url::Url::parse(&info.endpoint).is_ok_and(|endpoint| { - endpoint.scheme() == "http" - && endpoint.host_str() == Some("127.0.0.1") - && endpoint.port().is_some() - }) }) .collect(); assert_eq!( local_bidders.len(), 1, - "one default-active loopback passthrough is required" + "one explicitly configurable passthrough is required" ); let bidder = &local_bidders[0]; - let endpoint = url::Url::parse(&bidder.info.endpoint).unwrap(); + let endpoint = url::Url::parse(LOCAL_ENDPOINT).unwrap(); assert_eq!(endpoint.scheme(), "http"); assert_eq!(endpoint.host_str(), Some("127.0.0.1")); let listener = TcpListener::bind(( @@ -456,8 +455,7 @@ mod tests { transmit_precise_geo: mask & 4 == 0, transmit_tid: mask & 8 == 0, }; - let response_bytes = - dispatch(&app, request(&original, policy, &client, bidder.name())).await; + let response_bytes = dispatch(&app, request(&original, policy, &client, bidder)).await; let response_json: Value = serde_json::from_slice(&response_bytes).expect("auction JSON"); assert!( diff --git a/crates/auctioneer-adapter-axum/tests/resource_profile.rs b/crates/auctioneer-adapter-axum/tests/resource_profile.rs index 74f6e20..e887694 100644 --- a/crates/auctioneer-adapter-axum/tests/resource_profile.rs +++ b/crates/auctioneer-adapter-axum/tests/resource_profile.rs @@ -549,7 +549,7 @@ mod tests { } let endpoint = "http://127.0.0.1:8999"; let info = BidderInfo { - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), ..Default::default() }; let mut features: Vec<_> = diff --git a/crates/auctioneer-adapter-axum/tests/usersync_transport.rs b/crates/auctioneer-adapter-axum/tests/usersync_transport.rs index 11145e3..0f917c7 100644 --- a/crates/auctioneer-adapter-axum/tests/usersync_transport.rs +++ b/crates/auctioneer-adapter-axum/tests/usersync_transport.rs @@ -2,6 +2,7 @@ #[cfg(test)] mod tests { + use auctioneer_bidder::test_support::serving_catalog; use auctioneer_core::test_support::live_catalog; use edgezero_core::blob_envelope::BlobEnvelope; use serde_json::{Value, json}; @@ -925,12 +926,23 @@ mod tests { let bidders = server.request("GET", "/info/bidders", &[], b""); assert_eq!(bidders.status, 200); let names: Vec = serde_json::from_slice(&bidders.body).expect("bidder list"); - for row in live_catalog::feature_declarations("insecure-bidders") { + let configurable = serving_catalog::configured_endpoint_publications(); + assert_eq!( + configurable.len(), + 1, + "exercise the configurable publication" + ); + for row in configurable { assert!( - names.iter().any(|name| name == row.bidder), - "missing feature-gated identity {}", - row.bidder + names.iter().any(|name| name == row.name()), + "missing configurable identity {}", + row.name() ); + let details = server.request("GET", &format!("/info/bidders/{}", row.name()), &[], b""); + assert_eq!(details.status, 200); + let info: Value = + serde_json::from_slice(&details.body).expect("configurable bidder details"); + assert_eq!(info["status"], "DISABLED"); } let local = Server::start(Some(&loopback_config())); @@ -960,12 +972,24 @@ mod tests { let bidders = defaults.request("GET", "/info/bidders", &[], b""); assert_eq!(bidders.status, 200); let names: Vec = serde_json::from_slice(&bidders.body).expect("secure bidder list"); - for row in live_catalog::feature_declarations("insecure-bidders") { + let configurable = serving_catalog::configured_endpoint_publications(); + assert_eq!( + configurable.len(), + 1, + "exercise the configurable publication" + ); + for row in configurable { assert!( - !names.iter().any(|name| name == row.bidder), - "secure binary exposes gated identity {}", - row.bidder + names.iter().any(|name| name == row.name()), + "secure binary lacks configurable identity {}", + row.name() ); + let details = + defaults.request("GET", &format!("/info/bidders/{}", row.name()), &[], b""); + assert_eq!(details.status, 200); + let info: Value = + serde_json::from_slice(&details.body).expect("secure configurable bidder details"); + assert_eq!(info["status"], "DISABLED"); } } } diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml index dbe087a..816e2bd 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml @@ -1,6 +1,5 @@ key = "mocktioneer" factory = true -required_feature = "insecure-bidders" [settings.fields] endpoint = "optional_string" @@ -57,10 +56,10 @@ source_blob = "693721b93363c430d97fa8d4bf7649dfdd56ddaa" source_bytes = 497 source_sha256 = "86aef303c594f1a249f44cb0681a9cceec9c7887ef29ce4a228616f699e398c8" transformation = """\ - Existing effective TOML metadata retained; no verbatim metadata capture is claimed.""" + Effective capabilities retained with an operator-required destination; no verbatim metadata capture is claimed.""" port_differences = [ """\ - The existing insecure-bidders-only loopback endpoint and Auctioneer maintainer address differ \ + The operator-required endpoint and Auctioneer maintainer address differ \ from the source's hosted HTTPS endpoint and maintainer. This is development configuration, \ not deployment or routing approval.""", ] @@ -86,7 +85,7 @@ source_bytes = 10760 source_sha256 = "9d130cc11efd232f041473f0cd62c43806b9389d63b599c8ee0862b699e8bc58" [definitions.mocktioneer] -endpoint = "http://127.0.0.1:8787/openrtb2/auction" +defaultEnabled = false [definitions.mocktioneer.maintainer] email = "maintainer@auctioneer.example" diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs index 212fb4f..5c7257e 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/config.rs @@ -7,6 +7,7 @@ use validator::{Validate, ValidationError, ValidationErrors}; pub(super) struct Config<'a> { requested: Option<&'a Settings>, base: Option<&'a Settings>, + enabled: bool, } impl<'a> From> for Config<'a> { @@ -14,19 +15,24 @@ impl<'a> From> for Config<'a> { Self { requested: input.requested.and_then(BidderSettings::as_mocktioneer), base: input.base.and_then(BidderSettings::as_mocktioneer), + enabled: input.enabled, } } } impl Validate for Config<'_> { fn validate(&self) -> Result<(), ValidationErrors> { - endpoint(self.requested, self.base) - .map(|_endpoint| ()) - .map_err(|()| { - let mut errors = ValidationErrors::new(); - errors.add("endpoint", ValidationError::new("invalid")); - errors - }) + let selected = endpoint(self.requested, self.base).map_err(|()| { + let mut errors = ValidationErrors::new(); + errors.add("endpoint", ValidationError::new("invalid")); + errors + })?; + if self.enabled && selected.is_none() { + let mut errors = ValidationErrors::new(); + errors.add("endpoint", ValidationError::new("required_when_enabled")); + return Err(errors); + } + Ok(()) } } @@ -47,10 +53,21 @@ pub(super) fn endpoint<'a>( Ok(resolve(requested, base).endpoint()) } -pub(super) fn error(_: ValidationErrors) -> BidderConfigError { +pub(super) fn error(errors: &ValidationErrors) -> BidderConfigError { BidderConfigError { field: "endpoint", - reason: "invalid", + reason: if errors + .field_errors() + .get("endpoint") + .is_some_and(|entries| { + entries + .iter() + .any(|entry| entry.code == "required_when_enabled") + }) { + "required when enabled" + } else { + "invalid" + }, } } @@ -70,7 +87,40 @@ mod tests { } #[test] - fn omitted_and_null_endpoint_inherit_base_then_the_compiled_default() { + fn an_enabled_identity_requires_an_explicit_resolved_endpoint() { + let empty = settings(json!({})); + let null = settings(json!({"endpoint":null})); + for requested in [None, Some(&empty), Some(&null)] { + for base in [None, Some(&empty), Some(&null)] { + for enabled in [false, true] { + assert_eq!( + validate_config(BidderConfigInput { + requested, + base, + definition: None, + enabled, + }), + if enabled { + Err(BidderConfigError { + field: "endpoint", + reason: "required when enabled", + }) + } else { + Ok(()) + } + ); + } + assert!( + MocktioneerBidder + .configured_endpoint(requested, base) + .is_err() + ); + } + } + } + + #[test] + fn omitted_and_null_endpoint_inherit_base_without_an_implicit_destination() { let base = settings(json!({"endpoint":"https://base.example.test:8443/base?route=one"})); let explicit = settings(json!({"endpoint":"https://requested.example.test:9443/requested?route=two"})); @@ -122,13 +172,15 @@ mod tests { .endpoint(), expected ); - assert_eq!( - MocktioneerBidder - .configured_endpoint(requested, parent) - .unwrap() - .map(crate::ConfiguredEndpoint::as_str), - expected - ); + let selected = MocktioneerBidder.configured_endpoint(requested, parent); + if expected.is_some() { + assert_eq!( + selected.unwrap().map(crate::ConfiguredEndpoint::as_str), + expected + ); + } else { + assert!(selected.is_err()); + } for enabled in [false, true] { for candidate_definition in [None, Some(&definition)] { assert_eq!( @@ -138,7 +190,14 @@ mod tests { definition: candidate_definition, enabled }), - Ok(()) + if enabled && expected.is_none() { + Err(BidderConfigError { + field: "endpoint", + reason: "required when enabled", + }) + } else { + Ok(()) + } ); } } @@ -185,7 +244,7 @@ mod tests { } #[test] - fn exact_development_loopbacks_remain_admitted() { + fn exact_loopbacks_require_explicit_development_transport() { for literal in [ "http://127.0.0.1:8787/openrtb2/auction", "http://[::1]:8787/openrtb2/auction?route=one", @@ -201,16 +260,21 @@ mod tests { definition: None, enabled: false }), - Ok(()) - ); - assert_eq!( - MocktioneerBidder - .configured_endpoint(requested, base) - .unwrap() - .unwrap() - .as_str(), - literal + if cfg!(feature = "insecure-bidders") { + Ok(()) + } else { + Err(BidderConfigError { + field: "endpoint", + reason: "invalid", + }) + } ); + let selected = MocktioneerBidder.configured_endpoint(requested, base); + if cfg!(feature = "insecure-bidders") { + assert_eq!(selected.unwrap().unwrap().as_str(), literal); + } else { + assert!(selected.is_err()); + } } } } @@ -316,7 +380,13 @@ mod tests { endpoint: Some(at_limit), }; for (requested, base) in [(Some(&good), None), (None, Some(&good))] { - super::Config { requested, base }.validate().unwrap(); + super::Config { + requested, + base, + enabled: true, + } + .validate() + .unwrap(); } for literal in [ String::new(), @@ -327,14 +397,20 @@ mod tests { endpoint: Some(literal.clone()), }; for (requested, base) in [(Some(&bad), Some(&good)), (Some(&good), Some(&bad))] { - let errors = super::Config { requested, base }.validate().unwrap_err(); + let errors = super::Config { + requested, + base, + enabled: true, + } + .validate() + .unwrap_err(); assert!(!format!("{errors:?}").contains("private-canary")); let fields = errors.field_errors(); assert_eq!(fields.len(), 1); assert_eq!(fields["endpoint"][0].code, "invalid"); assert!(fields["endpoint"][0].params.is_empty()); assert_eq!( - super::error(errors), + super::error(&errors), BidderConfigError { field: "endpoint", reason: "invalid" diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json index 3cb1286..c467e9f 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json @@ -1,7 +1,7 @@ { "schema_version": 1, "implementation": "mocktioneer", - "manifest_sha256": "309822d54f89a89272c1c56b45c7d6df6a0026aa3569dfb9060ab21e6fd97b74", + "manifest_sha256": "03a6a92f76bacd5cce2ebd91e5221412127e2f1f1c038ee38bec53139ac2e0a1", "cases": [ { "name": "banner-and-unsent-sibling", diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs index fc3d0c2..4aa1285 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/mod.rs @@ -1,9 +1,8 @@ //! Mocktioneer bidder — snapshot E2E backend. //! -//! **Feature-gated.** Mocktioneer points at `http://127.0.0.1` which fails -//! `validate_endpoint`'s `https://`-only + localhost-reject rule in default -//! builds. The relaxation lives behind `#[cfg(feature = -//! "insecure-bidders")]`; in default builds this module does not exist. +//! Available in normal builds, disabled by default, with an explicit operator +//! endpoint required. The `insecure-bidders` feature permits local development +//! transport without changing publication or inventing a default destination. mod config; mod request; @@ -30,7 +29,7 @@ pub(super) fn validate_config( use validator::Validate as _; config::Config::from(input) .validate() - .map_err(config::error) + .map_err(|errors| config::error(&errors)) } impl Bidder for MocktioneerBidder { @@ -44,8 +43,9 @@ impl Bidder for MocktioneerBidder { base.and_then(BidderSettings::as_mocktioneer), ) .map_err(|()| BidderError::failed_to_request_bids("invalid configured bidder endpoint"))? - .map(ConfiguredEndpoint::new) - .transpose() + .ok_or_else(|| BidderError::failed_to_request_bids("configured bidder endpoint required")) + .and_then(ConfiguredEndpoint::new) + .map(Some) } fn debug_body_schema(&self) -> DebugBodySchema { @@ -87,19 +87,21 @@ mod tests { } #[test] - fn endpoint_declaration_preserves_development_gate_and_compiled_loopback() { + fn endpoint_declaration_requires_explicit_activation_and_destination() { let manifest: toml::Value = toml::from_str(include_str!("bidder.toml")).unwrap(); assert_eq!( manifest["settings"]["fields"]["endpoint"].as_str(), Some("optional_string") ); - assert_eq!( - manifest["required_feature"].as_str(), - Some("insecure-bidders") + assert!(manifest.get("required_feature").is_none()); + assert!( + manifest["definitions"]["mocktioneer"] + .get("endpoint") + .is_none() ); assert_eq!( - manifest["definitions"]["mocktioneer"]["endpoint"].as_str(), - Some("http://127.0.0.1:8787/openrtb2/auction") + manifest["definitions"]["mocktioneer"]["defaultEnabled"].as_bool(), + Some(false) ); for values in [ serde_json::json!({"unknown_endpoint":"https://example.test"}), diff --git a/crates/auctioneer-bidder/src/endpoint_builder.rs b/crates/auctioneer-bidder/src/endpoint_builder.rs index 4928957..08c6c02 100644 --- a/crates/auctioneer-bidder/src/endpoint_builder.rs +++ b/crates/auctioneer-bidder/src/endpoint_builder.rs @@ -94,12 +94,13 @@ impl<'info> EndpointBuilder<'info> { /// Construct a builder over a bidder's template. /// /// # Errors - /// Returns no errors today; the `Result` shape is the GDPR enforcement-stable - /// signature for when codegen-time template validation moves to a - /// runtime parser. + /// Rejects metadata without a compiled destination. Such bidders require + /// their configured endpoint through the request-time selection API. pub fn new(info: &'info BidderInfo) -> Result { Ok(Self { - template: &info.endpoint, + template: info.endpoint.as_deref().ok_or_else(|| { + EndpointBuilderError::MalformedTemplate("missing endpoint".to_owned()) + })?, host_allowlist: info.host_allowlist.as_deref().map(StringList::Owned), allowed_macros: StringList::Owned(&info.endpoint_macros), values: BTreeMap::new(), @@ -544,12 +545,27 @@ mod tests { fn info(endpoint: &str) -> BidderInfo { BidderInfo { - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), endpoint_macros: vec!["AccountID".into()], ..BidderInfo::default() } } + #[test] + fn absent_compiled_endpoint_requires_request_time_selection() { + let definition = BidderInfo::default(); + assert!( + matches!(EndpointBuilder::new(&definition), Err(EndpointBuilderError::MalformedTemplate(reason)) if reason == "missing endpoint") + ); + let request = typed_info("https://configured.example.test/auction", &[]); + assert_eq!( + EndpointBuilder::from_request(&request, &[]) + .build() + .unwrap(), + "https://configured.example.test/auction" + ); + } + #[test] fn query_expansion_encodes_each_pair_and_preserves_existing_query_and_macros() { for (endpoint, expected) in [ @@ -793,7 +809,7 @@ mod tests { let info = info("https://bidder.example.test/path?member_id=1&member_id=2"); let mut pool = Vec::with_capacity(META_BYTES_CAPACITY); let mut meta = OutboundRequestMetaBuilder::new(&mut pool, 1); - meta.set_url(&info.endpoint).unwrap(); + meta.set_url(info.endpoint.as_deref().unwrap()).unwrap(); let before = meta.written(); assert_eq!( EndpointBuilder::new(&info) @@ -804,7 +820,7 @@ mod tests { }) ); assert_eq!(meta.written(), before); - assert_eq!(meta.finish().url(), info.endpoint); + assert_eq!(meta.finish().url(), info.endpoint.as_deref().unwrap()); } #[test] @@ -835,7 +851,7 @@ mod tests { fn info_fixed_host() -> BidderInfo { BidderInfo { name: "fake_synthetic".into(), - endpoint: "https://hb.example.test/openrtb?acct={{.AccountID}}".into(), + endpoint: Some("https://hb.example.test/openrtb?acct={{.AccountID}}".into()), uses_https: true, host_allowlist: None, endpoint_macros: vec!["AccountID".into()], @@ -846,7 +862,7 @@ mod tests { fn info_host_varying() -> BidderInfo { BidderInfo { name: "fake_synthetic".into(), - endpoint: "https://{{.Host}}/openrtb".into(), + endpoint: Some("https://{{.Host}}/openrtb".into()), uses_https: true, host_allowlist: Some(vec!["eu.example.test".into(), "us.example.test".into()]), // {{.Host}} is excluded from endpoint_macros — it's the diff --git a/crates/auctioneer-bidder/src/registry/catalog.rs b/crates/auctioneer-bidder/src/registry/catalog.rs index 0d467a3..888edf2 100644 --- a/crates/auctioneer-bidder/src/registry/catalog.rs +++ b/crates/auctioneer-bidder/src/registry/catalog.rs @@ -779,7 +779,7 @@ mod tests { ] .map(|name| BidderInfo { name: name.into(), - endpoint: format!("https://{name}.example.test/bid"), + endpoint: Some(format!("https://{name}.example.test/bid")), gvl_vendor_id: Some(if name == "fake_base" { 7 } else { 9 }), disabled: name == "fake_child", ..BidderInfo::default() diff --git a/crates/auctioneer-bidder/src/request_info.rs b/crates/auctioneer-bidder/src/request_info.rs index 387d6d8..68936c0 100644 --- a/crates/auctioneer-bidder/src/request_info.rs +++ b/crates/auctioneer-bidder/src/request_info.rs @@ -44,9 +44,9 @@ pub struct BidderRequestInfo<'req> { /// `FailedToRequestBids`. Publisher id and account id are /// intentionally distinct — do NOT fall back to `account_id`. pub publisher_id: Option<&'req str>, - /// `BidderInfo.endpoint` as a TEMPLATE. The bidder constructs the - /// final URL via `EndpointBuilder`; this is NOT the final outbound - /// URL. + /// The selected operator URL or a compiled `BidderInfo.endpoint` template. + /// The bidder constructs the final URL via `EndpointBuilder`; configured + /// literals have no endpoint macros. pub endpoint_template: &'req str, /// Macro names permitted by this bidder's endpoint template. /// E.g. `&["AccountID", "PublisherID"]`. diff --git a/crates/auctioneer-bidder/src/test_support/fake_bidder/controls.rs b/crates/auctioneer-bidder/src/test_support/fake_bidder/controls.rs index 7a1b1cb..726c711 100644 --- a/crates/auctioneer-bidder/src/test_support/fake_bidder/controls.rs +++ b/crates/auctioneer-bidder/src/test_support/fake_bidder/controls.rs @@ -27,7 +27,7 @@ pub(super) fn record_factory_call() { pub fn info() -> &'static BidderInfo { static INFO: LazyLock = LazyLock::new(|| BidderInfo { name: "fake_bb".into(), - endpoint: "https://bidder.example.test/openrtb".into(), + endpoint: Some("https://bidder.example.test/openrtb".into()), uses_https: true, ..BidderInfo::default() }); @@ -87,7 +87,10 @@ mod tests { let metadata = info(); assert!(ptr::eq(metadata, info())); assert_eq!(metadata.name, "fake_bb"); - assert_eq!(metadata.endpoint, "https://bidder.example.test/openrtb"); + assert_eq!( + metadata.endpoint.as_deref(), + Some("https://bidder.example.test/openrtb") + ); assert!(metadata.uses_https); } } diff --git a/crates/auctioneer-bidder/src/test_support/serving_catalog.rs b/crates/auctioneer-bidder/src/test_support/serving_catalog.rs index d1dfce6..e3fe76f 100644 --- a/crates/auctioneer-bidder/src/test_support/serving_catalog.rs +++ b/crates/auctioneer-bidder/src/test_support/serving_catalog.rs @@ -163,7 +163,7 @@ pub fn passthrough_info() -> &'static BidderInfo { && info.capabilities.site.contains(&MediaType::Banner) // Retention tests inspect the endpoint and debug body directly. && info.debug_allow - && !info.endpoint.contains("{{") + && info.endpoint.as_deref().is_some_and(|endpoint| !endpoint.contains("{{")) && info.endpoint_compression.is_none() && row.owner_expected_request_body.as_ref() == Some(&row.owner_request) }) @@ -192,7 +192,7 @@ fn default_active(mut info: &BidderInfo) -> bool { } /// Existing default-active publications for baseline handler/receipt controls. -/// New opt-in native flows require their own funded generated-App acceptance; +/// Opt-in publications require their own generated-App acceptance; /// [`published`] remains the complete catalog and is never filtered here. /// /// # Panics @@ -210,6 +210,26 @@ pub fn default_active_publications() -> Vec { rows } +/// Publications that require an explicit operator-configured endpoint. +/// Generated-App acceptance exercises this cohort separately from the frozen +/// default-active and native-schema cohorts, using each implementation's fixtures. +/// +/// # Panics +/// Panics if catalog discovery fails or an endpoint-free publication is active by default. +#[must_use] +pub fn configured_endpoint_publications() -> Vec { + published() + .into_iter() + .filter(|row| row.info.endpoint.is_none()) + .inspect(|row| { + assert!( + !row.info.disabled && !default_active(row.info), + "endpoint-free publications require explicit activation" + ); + }) + .collect() +} + /// Default-active counterpart of [`site_media`] for unfunded baseline tests. /// /// # Panics @@ -272,7 +292,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] - fn baseline_and_native_admission_cohorts_cover_each_publication_once() { + fn baseline_native_and_configured_endpoint_cohorts_cover_each_publication_once() { use crate::schema::native_schema_required; use std::collections::BTreeSet; @@ -281,19 +301,30 @@ mod tests { .into_iter() .map(|row| row.name()) .collect(); + let configured: BTreeSet<_> = super::configured_endpoint_publications() + .into_iter() + .map(|row| row.name()) + .collect(); let mut native = 0; for row in &rows { let controlled = native_schema_required(row.name()); - assert_ne!( - baseline.contains(row.name()), - controlled, + assert_eq!( + usize::from(baseline.contains(row.name())) + + usize::from(controlled) + + usize::from(configured.contains(row.name())), + 1, "{} must have exactly one explicit acceptance cohort", row.name() ); native += usize::from(controlled); } assert_eq!(native, 14, "13 new priority flows plus canonical ancestry"); - assert_eq!(baseline.len() + native, rows.len()); + assert_eq!( + configured.len(), + 1, + "explicit configured-endpoint acceptance" + ); + assert_eq!(baseline.len() + native + configured.len(), rows.len()); } use auctioneer_config_data::types::BidderInfo; diff --git a/crates/auctioneer-bidder/tests/catalog_alloc.rs b/crates/auctioneer-bidder/tests/catalog_alloc.rs index ab7a48a..a119c0a 100644 --- a/crates/auctioneer-bidder/tests/catalog_alloc.rs +++ b/crates/auctioneer-bidder/tests/catalog_alloc.rs @@ -65,7 +65,7 @@ mod tests { for name in [base, child] { infos.push(BidderInfo { name: name.into(), - endpoint: "https://fake-layout.example.test/bid".into(), + endpoint: Some("https://fake-layout.example.test/bid".into()), ..BidderInfo::default() }); bindings.push(BidderImplementationBinding { diff --git a/crates/auctioneer-bidder/tests/catalog_publication.rs b/crates/auctioneer-bidder/tests/catalog_publication.rs index e1454e7..a7b3058 100644 --- a/crates/auctioneer-bidder/tests/catalog_publication.rs +++ b/crates/auctioneer-bidder/tests/catalog_publication.rs @@ -39,7 +39,7 @@ mod tests { }; let mut native_publications = false; let mut portable_publications = false; - let mut feature_publications = false; + let mut configurable_publications = false; for entry in fs::read_dir(root).unwrap() { let path = entry.unwrap().path().join("bidder.toml"); if !path.is_file() { @@ -75,7 +75,13 @@ mod tests { ); native_publications |= native; portable_publications |= !native; - feature_publications |= feature.is_some(); + configurable_publications |= definitions + .and_then(|rows| rows.get(name)) + .is_some_and(|row| { + row.get("endpoint").is_none() + && row.get("defaultEnabled").and_then(toml::Value::as_bool) + == Some(false) + }); let target_permits = !matches!((native, ARCH), (true, "wasm32")); if enabled && target_permits { assert!( @@ -95,7 +101,10 @@ mod tests { portable_publications, "portable positive controls must exist" ); - assert!(feature_publications, "feature selection must be exercised"); + assert!( + configurable_publications, + "operator-configured publication must be exercised in both feature lanes" + ); assert!( !catalog.selected.is_empty(), "publication cannot pass vacuously" diff --git a/crates/auctioneer-bidder/tests/endpoint_expansion_bound.rs b/crates/auctioneer-bidder/tests/endpoint_expansion_bound.rs index eb19a72..4121750 100644 --- a/crates/auctioneer-bidder/tests/endpoint_expansion_bound.rs +++ b/crates/auctioneer-bidder/tests/endpoint_expansion_bound.rs @@ -161,7 +161,7 @@ mod tests { fn info_with_account_macro() -> BidderInfo { BidderInfo { name: "fake_synthetic".into(), - endpoint: "https://hb.example.test/openrtb?acct={{.AccountID}}".into(), + endpoint: Some("https://hb.example.test/openrtb?acct={{.AccountID}}".into()), uses_https: true, host_allowlist: None, endpoint_macros: vec!["AccountID".into()], @@ -255,7 +255,7 @@ mod tests { let per_value = META_BIDDER_REGION_BYTES.saturating_div(2); let info = BidderInfo { name: "fake_synthetic".into(), - endpoint: "https://hb.example.test/o?a={{.AccountID}}&p={{.PublisherID}}".into(), + endpoint: Some("https://hb.example.test/o?a={{.AccountID}}&p={{.PublisherID}}".into()), uses_https: true, host_allowlist: None, endpoint_macros: vec!["AccountID".into(), "PublisherID".into()], @@ -291,7 +291,9 @@ mod tests { let peak = peak_bytes_of(move || { let measured_info = BidderInfo { name: "fake_synthetic".into(), - endpoint: "https://hb.example.test/o?a={{.AccountID}}&p={{.PublisherID}}".into(), + endpoint: Some( + "https://hb.example.test/o?a={{.AccountID}}&p={{.PublisherID}}".into(), + ), uses_https: true, host_allowlist: None, endpoint_macros: vec!["AccountID".into(), "PublisherID".into()], @@ -417,7 +419,7 @@ mod tests { // copy proportional to these escaping-heavy inputs before refusing. let oversized = " &+=%/?#".repeat(OVERSIZE_VALUE_BYTES.saturating_div(8)); let info = BidderInfo { - endpoint: "https://hb.example.test/o?existing=1".into(), + endpoint: Some("https://hb.example.test/o?existing=1".into()), ..BidderInfo::default() }; for (label, name, value) in [ @@ -465,10 +467,10 @@ mod tests { #[test] fn appended_query_encoding_is_exact_at_the_metadata_boundary() { let info = BidderInfo { - endpoint: "https://hb.example.test/o?existing=1".into(), + endpoint: Some("https://hb.example.test/o?existing=1".into()), ..BidderInfo::default() }; - let encoded_prefix = format!("{}&n+%2B%26=", info.endpoint); + let encoded_prefix = format!("{}&n+%2B%26=", info.endpoint.as_deref().unwrap()); for (target, admitted) in [ (META_BIDDER_REGION_BYTES.saturating_sub(1), true), (META_BIDDER_REGION_BYTES, true), @@ -533,7 +535,7 @@ mod tests { #[test] fn query_replacement_uses_only_the_existing_pool_at_the_encoded_boundary() { let info = BidderInfo { - endpoint: "https://hb.example.test/o?token%5Fid=1&keep=a%2Bb&token_id=2".into(), + endpoint: Some("https://hb.example.test/o?token%5Fid=1&keep=a%2Bb&token_id=2".into()), ..BidderInfo::default() }; let prefix = "https://hb.example.test/o?keep=a%2Bb&token_id="; @@ -608,7 +610,7 @@ mod tests { ), ] { let info = BidderInfo { - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), ..BidderInfo::default() }; let builder = EndpointBuilder::new(&info).unwrap(); @@ -639,7 +641,7 @@ mod tests { let fitting = META_BIDDER_REGION_BYTES - 4 - prefix.len(); for width in [fitting - 1, fitting, fitting + 1] { let info = BidderInfo { - endpoint: format!("{prefix}{}", "x".repeat(width)), + endpoint: Some(format!("{prefix}{}", "x".repeat(width))), ..BidderInfo::default() }; let builder = EndpointBuilder::new(&info).unwrap(); @@ -838,7 +840,7 @@ mod tests { let fitting = META_BIDDER_REGION_BYTES.saturating_sub(prefix.len()); let info = BidderInfo { name: "fake_synthetic".into(), - endpoint: template, + endpoint: Some(template), uses_https: true, host_allowlist: None, endpoint_macros: vec!["AccountID".into()], diff --git a/crates/auctioneer-bidder/tests/fake_registry/mod.rs b/crates/auctioneer-bidder/tests/fake_registry/mod.rs index 3403000..60f9ee8 100644 --- a/crates/auctioneer-bidder/tests/fake_registry/mod.rs +++ b/crates/auctioneer-bidder/tests/fake_registry/mod.rs @@ -24,7 +24,7 @@ pub fn infos() -> &'static [BidderInfo] { .iter() .map(|(name, definition)| BidderInfo { name: name.clone(), - endpoint: definition["endpoint"].as_str().unwrap().into(), + endpoint: Some(definition["endpoint"].as_str().unwrap().into()), uses_https: true, ..BidderInfo::default() }) diff --git a/crates/auctioneer-bidder/tests/feature_exports_source_conformance.rs b/crates/auctioneer-bidder/tests/feature_exports_source_conformance.rs index 30c77e2..ca2ceff 100644 --- a/crates/auctioneer-bidder/tests/feature_exports_source_conformance.rs +++ b/crates/auctioneer-bidder/tests/feature_exports_source_conformance.rs @@ -205,16 +205,16 @@ fn main() { !declarations.is_empty(), "public declarations must not be empty" ); - let mut gated = 0_usize; + let mut settings_owners = 0_usize; for declaration in declarations { let enabled = match declaration.required_feature { None => true, Some("insecure-bidders") => { - gated += 1; insecure } Some(_) => panic!("unexpected factory feature"), }; + settings_owners += usize::from(enabled && declaration.has_settings); let matching: Vec<_> = factories .iter() .filter(|row| row.key == declaration.key) @@ -229,7 +229,7 @@ fn main() { let _: Box = (registration.factory)(); } } - assert!(gated > 0, "feature-gated declarations must not be empty"); + assert!(settings_owners > 0, "typed settings owners must be available in both feature lanes"); for registration in factories { assert!( declarations.iter().any(|row| row.key == registration.key), diff --git a/crates/auctioneer-bidder/tests/native_schema_resources.rs b/crates/auctioneer-bidder/tests/native_schema_resources.rs index fbe2d96..475aa4c 100644 --- a/crates/auctioneer-bidder/tests/native_schema_resources.rs +++ b/crates/auctioneer-bidder/tests/native_schema_resources.rs @@ -806,14 +806,8 @@ mod tests { assert_eq!(coverage.census.authored_cases, 120); assert_eq!(coverage.census.stress_cases, 564); assert_eq!(coverage.census.evaluations, 2_526); - // The development feature adds one legacy schema, eight authored - // cases and 35 shape cases, each evaluated three times. - let (legacy_bindings, authored, stress, evaluations) = if cfg!(feature = "insecure-bidders") - { - (3, 33, 105, 489) - } else { - (2, 25, 70, 360) - }; + // The configurable legacy schema is now compiled in both feature lanes. + let (legacy_bindings, authored, stress, evaluations) = (3, 33, 105, 489); assert_eq!( coverage.legacy_bindings.len(), legacy_bindings, diff --git a/crates/auctioneer-bidder/tests/registry_join.rs b/crates/auctioneer-bidder/tests/registry_join.rs index 4d06e8b..69d7034 100644 --- a/crates/auctioneer-bidder/tests/registry_join.rs +++ b/crates/auctioneer-bidder/tests/registry_join.rs @@ -576,7 +576,7 @@ mod tests { BidderName::new(definition.canonical_base()), "registry-account", None, - &info.endpoint, + info.endpoint.as_deref().unwrap(), &[], None, None, @@ -596,7 +596,7 @@ mod tests { assert_eq!(outcome.accepted.len(), 1, "factory {}", info.name); let outbound = &outcome.accepted[0]; assert_eq!(outbound.method(), "POST"); - assert_eq!(outbound.url(), info.endpoint); + assert_eq!(outbound.url(), info.endpoint.as_deref().unwrap()); assert_eq!(outbound.covered_imp_ids().collect::>(), ["i"]); for expected in [ ("Content-Type", "application/json"), @@ -737,7 +737,7 @@ mod tests { BidderName::new(definition.canonical_base()), "registry-account", None, - &info.endpoint, + info.endpoint.as_deref().unwrap(), &[], None, None, @@ -756,7 +756,7 @@ mod tests { assert_eq!(outcome.suppressed_error_count, 0); assert_eq!(outcome.accepted.len(), 1); let outbound = &outcome.accepted[0]; - assert_eq!(outbound.url(), info.endpoint); + assert_eq!(outbound.url(), info.endpoint.as_deref().unwrap()); assert_eq!(outbound.method(), "PATCH"); assert!( outbound diff --git a/crates/auctioneer-bidder/tests/registry_source_conformance.rs b/crates/auctioneer-bidder/tests/registry_source_conformance.rs index 2673434..193398b 100644 --- a/crates/auctioneer-bidder/tests/registry_source_conformance.rs +++ b/crates/auctioneer-bidder/tests/registry_source_conformance.rs @@ -451,18 +451,21 @@ mod tests { } #[test] - fn feature_predicate_controls_all_declared_factories_and_metadata() { - let declarations: Vec<_> = auctioneer_bidder::bidder_declarations() - .iter() - .filter(|row| row.required_feature == Some("insecure-bidders")) - .collect(); + fn publication_predicate_controls_all_declared_factories_and_metadata() { + let declarations: Vec<_> = auctioneer_bidder::bidder_declarations().iter().collect(); assert!(!declarations.is_empty()); + let mut metadata_count = 0_usize; for declaration in declarations { + let selected = match declaration.required_feature { + None => true, + Some("insecure-bidders") => cfg!(feature = "insecure-bidders"), + Some(_) => panic!("unreviewed factory feature"), + }; assert_eq!( auctioneer_bidder::compiled_bidders() .iter() .any(|row| row.key == declaration.key), - cfg!(feature = "insecure-bidders"), + selected, "{}", declaration.key, ); @@ -470,18 +473,19 @@ mod tests { .iter() .filter(|row| row.implementation == declaration.key) .collect(); - assert!( - !metadata.is_empty(), - "feature-gated metadata control must not be empty" - ); + metadata_count += metadata.len(); for row in metadata { assert_eq!(row.required_feature, declaration.required_feature); assert_eq!( auctioneer_config_data::bidder(row.bidder).is_some(), - cfg!(feature = "insecure-bidders") + selected ); } } + assert!( + metadata_count > 0, + "metadata publication controls must execute" + ); } #[test] diff --git a/crates/auctioneer-bidder/tests/registry_workspace/registry_probe.rs b/crates/auctioneer-bidder/tests/registry_workspace/registry_probe.rs index bd0a064..476f9f1 100644 --- a/crates/auctioneer-bidder/tests/registry_workspace/registry_probe.rs +++ b/crates/auctioneer-bidder/tests/registry_workspace/registry_probe.rs @@ -5,7 +5,9 @@ use auctioneer_bidder::{ OutboundRequestSink, RegistryInputs, SinkLimits, ValidatedRegistryPlan, bidder_declarations, compiled_bidders, }; -use auctioneer_config_data::{bidder_implementation_bindings, bidder_metadata_declarations, bidders}; +use auctioneer_config_data::{ + bidder_implementation_bindings, bidder_metadata_declarations, bidders, +}; use auctioneer_openrtb::types::bid_request::{BidRequest, Imp}; use serde_json::{Value, json}; @@ -51,7 +53,7 @@ fn execute(plan: &ValidatedRegistryPlan, name: &str) -> Value { BidderName::new(definition.canonical_base()), "discovery-account", None, - &info.endpoint, + info.endpoint.as_deref().unwrap(), ¯os, None, None, diff --git a/crates/auctioneer-bidder/tests/schema_source_conformance.rs b/crates/auctioneer-bidder/tests/schema_source_conformance.rs index d56e03c..d45d3ce 100644 --- a/crates/auctioneer-bidder/tests/schema_source_conformance.rs +++ b/crates/auctioneer-bidder/tests/schema_source_conformance.rs @@ -2527,15 +2527,7 @@ mod tests { .iter() .map(|row| (row.bidder, row.implementation)) .collect(); - let active_count = match ( - cfg!(target_arch = "wasm32"), - cfg!(feature = "insecure-bidders"), - ) { - (false, false) => 17, - (false, true) => 18, - (true, false) => 3, - (true, true) => 4, - }; + let active_count = if cfg!(target_arch = "wasm32") { 4 } else { 18 }; if manifest["planned_roots"] .as_array() .ok_or("planned roots")? @@ -2558,7 +2550,7 @@ mod tests { return Err("root/publication membership"); } for (lane_name, count, roots, members) in - [("secure", 15_usize, 17, 17), ("development", 16, 18, 18)] + [("secure", 16_usize, 18, 18), ("development", 16, 18, 18)] { let lane = &manifest["lanes"][lane_name]; // Inspection S remains published declarations plus planned roots, diff --git a/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs b/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs index ab09b44..d7514da 100644 --- a/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs +++ b/crates/auctioneer-bidder/tests/support/generated_exchange_settings.rs @@ -72,7 +72,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://bidder.example.test/auction".into(), + endpoint: Some("https://bidder.example.test/auction".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs index 87dce92..e859f01 100644 --- a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs +++ b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs @@ -207,10 +207,13 @@ pub fn assert_artifact(path: &Path, digest: &Digest) -> Result<()> { fn config_document(candidate: &Candidate, id: u16) -> Result { let nodes = variant_nodes(candidate, id)?; - let bidder = fixture_bidder( - auctioneer_config_data::bidder_metadata_declarations(), - auctioneer_config_data::bidders(), - )?; + let declarations = auctioneer_config_data::bidder_metadata_declarations(); + let bidder = fixture_bidder(declarations, auctioneer_config_data::bidders())?; + let implementation = declarations + .iter() + .find(|row| row.bidder == bidder) + .ok_or(HarnessError::new("fixture_unavailable"))? + .implementation; let aliases = geometry::aliases(candidate.maximum_load.calls)? .into_iter() .map(|name| (name, bidder)) @@ -234,13 +237,29 @@ fn config_document(candidate: &Candidate, id: u16) -> Result { }, }, "accounts": {"q2_allowed": {"debug_allow": true}, "q2_refused": {"debug_allow": false}}, - "bidders": {(bidder): {"timeout_ms": 500_u16, "sample_rate": 1.0_f64}}, + "bidders": {(bidder): { + "enabled": true, "implementation": implementation, + "settings": {"endpoint": FIXTURE_ENDPOINT}, + "timeout_ms": 500_u16, "sample_rate": 1.0_f64, + }}, }); toml::Value::try_from(document).map_err(|_error| HarnessError::new("invalid_input")) } fn historical_config_document(candidate: &Candidate, id: u16) -> Result { - let current = config_document(candidate, id)?; + let mut current = config_document(candidate, id)?; + let entries = current + .get_mut("bidders") + .and_then(toml::Value::as_table_mut) + .ok_or(HarnessError::new("invalid_input"))?; + for (_, entry) in entries.iter_mut() { + let fields = entry + .as_table_mut() + .ok_or(HarnessError::new("invalid_input"))?; + fields.remove("enabled"); + fields.remove("implementation"); + fields.remove("settings"); + } let invalid = HarnessError::new("invalid_input"); let host = current.get("host").ok_or(invalid)?; let document = json!({ @@ -261,20 +280,19 @@ fn fixture_bidder( declarations: &[BidderMetadataDeclaration], infos: &[BidderInfo], ) -> Result<&'static str> { - // A secure-default harness builds an insecure-enabled child CLI/server. Its - // generated declarations retain that child's feature-gated public binding; - // config push and the positive socket/debug probes still verify real serving. + // The native child opts into local transport. Discover its configurable, + // default-disabled publication from generated bindings and metadata. let mut candidates = declarations.iter().filter(|declaration| { - declaration.required_feature == Some("insecure-bidders") + declaration.required_feature.is_none() && infos .iter() .find(|info| info.name == declaration.bidder) - .is_none_or(|info| { + .is_some_and(|info| { !info.disabled - && !info.uses_https + && info.default_enabled == Some(false) && info.debug_allow && info.gvl_vendor_id.is_none() - && info.endpoint == FIXTURE_ENDPOINT + && info.endpoint.is_none() && info.endpoint_macros.is_empty() && info.endpoint_compression.is_none() && info.capabilities.site.contains(&MediaType::Banner) @@ -545,25 +563,40 @@ mod tests { json!({"auctioneer_runtime": serde_json::to_string(&envelope).unwrap(), "other_store": "PRESERVE_OTHER_STORE"}) } + fn configurable_info(name: &str) -> BidderInfo { + BidderInfo { + name: name.to_owned(), + default_enabled: Some(false), + debug_allow: true, + capabilities: Capabilities { + site: vec![MediaType::Banner], + ..Capabilities::default() + }, + ..BidderInfo::default() + } + } + #[test] - fn fixture_selection_uses_generated_feature_bindings_without_a_bidder_identity() { + fn fixture_selection_requires_unique_configurable_metadata_without_a_bidder_identity() { let local = BidderMetadataDeclaration { bidder: "fake_local", implementation: "fake_transport", - required_feature: Some("insecure-bidders"), + required_feature: None, }; let ordinary = BidderMetadataDeclaration { bidder: "fake_secure", - required_feature: None, ..local }; - // The parent harness need not carry its insecure-enabled child's metadata. assert_eq!( - fixture_bidder(&[ordinary, local], &[]).unwrap(), + fixture_bidder(&[ordinary, local], &[configurable_info("fake_local")]).unwrap(), "fake_local" ); assert_eq!( - fixture_bidder(&[ordinary], &[]).unwrap_err(), + fixture_bidder(&[local], &[]).unwrap_err(), + HarnessError::new("fixture_unavailable") + ); + assert_eq!( + fixture_bidder(&[ordinary], &[configurable_info("fake_local")]).unwrap_err(), HarnessError::new("fixture_unavailable") ); let sibling = BidderMetadataDeclaration { @@ -571,7 +604,14 @@ mod tests { ..local }; assert_eq!( - fixture_bidder(&[local, sibling], &[]).unwrap_err(), + fixture_bidder( + &[local, sibling], + &[ + configurable_info("fake_local"), + configurable_info("fake_sibling") + ] + ) + .unwrap_err(), HarnessError::new("fixture_unavailable") ); } @@ -581,28 +621,19 @@ mod tests { let local = BidderMetadataDeclaration { bidder: "fake_local", implementation: "fake_transport", - required_feature: Some("insecure-bidders"), - }; - let info = BidderInfo { - name: local.bidder.to_owned(), - endpoint: FIXTURE_ENDPOINT.to_owned(), - debug_allow: true, - capabilities: Capabilities { - site: vec![MediaType::Banner], - ..Capabilities::default() - }, - ..BidderInfo::default() + required_feature: None, }; + let info = configurable_info(local.bidder); assert_eq!( fixture_bidder(&[local], from_ref(&info)).unwrap(), "fake_local" ); let disqualify: [fn(&mut BidderInfo); 8] = [ |row| row.disabled = true, - |row| row.uses_https = true, + |row| row.default_enabled = None, |row| row.debug_allow = false, |row| row.gvl_vendor_id = Some(1), - |row| row.endpoint = "http://127.0.0.1:8788/openrtb2/auction".to_owned(), + |row| row.endpoint = Some("http://127.0.0.1:8788/openrtb2/auction".to_owned()), |row| row.endpoint_macros.push("AccountID".to_owned()), |row| row.endpoint_compression = Some(Compression::Gzip), |row| row.capabilities.site.clear(), @@ -698,7 +729,15 @@ mod tests { assert_eq!(historical["runtime"]["aliases"], current["host"]["aliases"]); assert_eq!(historical["runtime"]["gdpr"], current["host"]["gdpr"]); assert_eq!(historical["runtime"]["accounts"], current["accounts"]); - assert_eq!(historical["runtime"]["bidders"], current["bidders"]); + let historical_bidders = historical["runtime"]["bidders"].as_table().unwrap(); + assert_eq!(historical_bidders.len(), 1); + for entry in historical_bidders.values() { + assert_eq!( + *entry, + toml::Value::try_from(json!({"timeout_ms": 500_u16, "sample_rate": 1.0_f64})) + .unwrap() + ); + } } #[test] diff --git a/crates/auctioneer-config-data/Cargo.toml b/crates/auctioneer-config-data/Cargo.toml index f667402..71bdf8d 100644 --- a/crates/auctioneer-config-data/Cargo.toml +++ b/crates/auctioneer-config-data/Cargo.toml @@ -16,8 +16,8 @@ name = "build_failure_harness" test = false [features] -# The insecure-bidders feature gates the local loopback fixture endpoint -# through build.rs syncer-URL template validation. Forwarded from +# The insecure-bidders feature permits development transport during configured +# endpoint and build-time URL validation. It does not enable Mocktioneer. Forwarded from # auctioneer-bidder/auctioneer-exchange/auctioneer-core/adapter crates. insecure-bidders = [ "auctioneer-config/insecure-bidders", diff --git a/crates/auctioneer-config-data/build.rs b/crates/auctioneer-config-data/build.rs index d70e95c..8dfc3a2 100644 --- a/crates/auctioneer-config-data/build.rs +++ b/crates/auctioneer-config-data/build.rs @@ -1,16 +1,18 @@ //! Reads bidder-owned manifests and `data/*`, then emits `OUT_DIR/generated.rs`. //! -//! Loopback endpoint URLs (for example a local `http://127.0.0.1:8787/` fixture) -//! are only allowed when the `insecure-bidders` feature is enabled. -//! Gating is via `CARGO_FEATURE_INSECURE_BIDDERS` -- Cargo sets this for -//! crates declaring the feature when it's active. +//! Compiled development URL defaults require the owning manifest's +//! `insecure-bidders` gate; publication follows the active Cargo features. +//! A default-disabled factory with typed endpoint settings may instead omit +//! its compiled destination and require explicit operator configuration. //! //! Pure rendering logic (TOML types + `render_*` helpers) lives in //! `auctioneer-config`'s `build-support` library modules. This file keeps //! the coordinators that read TOML off disk, validate URLs / hostAllowlist entries, and //! stitch imports + module boilerplate around the rendered struct //! literals. -use auctioneer_config::{build_catalog, build_disclosure as disclosure, codegen}; +use auctioneer_config::{ + bidder_manifest::SettingKind, build_catalog, build_disclosure as disclosure, codegen, +}; use auctioneer_gvl_schema::{Manifest, ManifestEntry, validate_gvl_json, validate_manifest}; use codegen::build_support::{ @@ -234,13 +236,30 @@ fn load_bidders_from( .clone_from(&supported_endpoint_macros); parsed.native_only = manifest.native_only; - validate_endpoint_authority(&name, &parsed.endpoint, allow_insecure)?; - validate_host_allowlist( - &name, - &parsed.endpoint, - parsed.host_allowlist.as_deref(), - allow_insecure, - )?; + match parsed.endpoint.as_deref() { + Some(endpoint) => { + validate_endpoint_authority(&name, endpoint, allow_insecure)?; + validate_host_allowlist( + &name, + endpoint, + parsed.host_allowlist.as_deref(), + allow_insecure, + )?; + } + None if !manifest.factory + || parsed.default_enabled != Some(false) + || !manifest.settings.as_ref().is_some_and(|settings| { + matches!( + settings.fields.get("endpoint"), + Some(SettingKind::OptionalString) + ) + }) + || parsed.host_allowlist.is_some() => + { + return Err(format!("bidder {name:?}: missing endpoint requires a default-disabled factory with typed endpoint settings and no hostAllowlist").into()); + } + None => {} + } validate_bidder_enums(&name, &parsed)?; codegen::usersync::validate_skip_when(&parsed.user_sync.skip_when)?; codegen::usersync::validate_syncer_key(parsed.user_sync.key.as_deref())?; diff --git a/crates/auctioneer-config-data/tests/bidder_storage.rs b/crates/auctioneer-config-data/tests/bidder_storage.rs index 42ffd10..60e3486 100644 --- a/crates/auctioneer-config-data/tests/bidder_storage.rs +++ b/crates/auctioneer-config-data/tests/bidder_storage.rs @@ -334,7 +334,7 @@ mod tests { let _: &Option = bidder_timeout_ms; self.string(name); self.optional_string(alias_of.as_ref()); - self.string(endpoint); + self.optional_string(endpoint.as_ref()); if let Some(hosts) = host_allowlist { self.strings(hosts); } diff --git a/crates/auctioneer-config-data/tests/build_failure_harness.rs b/crates/auctioneer-config-data/tests/build_failure_harness.rs index 4f2005b..d9e1ca6 100644 --- a/crates/auctioneer-config-data/tests/build_failure_harness.rs +++ b/crates/auctioneer-config-data/tests/build_failure_harness.rs @@ -1417,6 +1417,98 @@ mod tests { ); } + #[test] + fn missing_compiled_endpoint_requires_explicit_configuration_contract() { + for (label, default_enabled, setting, endpoint, hosts, accepted) in [ + ( + "configured_destination", + Some(false), + Some("optional_string"), + None, + false, + true, + ), + ( + "missing_activation_default", + None, + Some("optional_string"), + None, + false, + false, + ), + ( + "active_without_destination", + Some(true), + Some("optional_string"), + None, + false, + false, + ), + ( + "missing_settings_contract", + Some(false), + None, + None, + false, + false, + ), + ( + "wrong_settings_kind", + Some(false), + Some("optional_i64"), + None, + false, + false, + ), + ( + "allowlist_without_destination", + Some(false), + Some("optional_string"), + None, + true, + false, + ), + ( + "empty_destination", + Some(false), + Some("optional_string"), + Some(""), + false, + false, + ), + ] { + let harness = build_bidder_tempdir(label, "fake_subject.toml", BASELINE_FIXTURE); + let path = harness.fixture("fake_fixture_adapter").join("bidder.toml"); + let mut manifest: toml::Value = + toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + if let Some(kind) = setting { + manifest.as_table_mut().unwrap().insert( + "settings".into(), + toml::Value::try_from(serde_json::json!({"fields":{"endpoint":kind}})).unwrap(), + ); + } + let subject = manifest["definitions"]["fake_subject"] + .as_table_mut() + .unwrap(); + assert!(subject.remove("endpoint").is_some()); + if let Some(enabled) = default_enabled { + subject.insert("defaultEnabled".into(), enabled.into()); + } + if let Some(value) = endpoint { + subject.insert("endpoint".into(), value.into()); + } + if hosts { + subject.insert("hostAllowlist".into(), vec!["approved.example.test"].into()); + } + fs::write(&path, toml::to_string(&manifest).unwrap()).unwrap(); + let (status, stderr) = cargo_build_with_bidder_dir(&harness); + assert_eq!(status.success(), accepted, "{label}: {stderr}"); + if !accepted { + assert!(stderr.contains("endpoint"), "{label}: {stderr}"); + } + } + } + #[test] fn child_build_fails_with_http_endpoint_in_secure_dir() { let bidder_dir = build_bidder_tempdir( diff --git a/crates/auctioneer-config-data/tests/codegen.rs b/crates/auctioneer-config-data/tests/codegen.rs index a904563..fb19d65 100644 --- a/crates/auctioneer-config-data/tests/codegen.rs +++ b/crates/auctioneer-config-data/tests/codegen.rs @@ -487,7 +487,7 @@ mod tests { native_only: false, alias_of: None, default_enabled: None, - endpoint: endpoint.to_owned(), + endpoint: Some(endpoint.to_owned()), maintainer: None, disabled: None, endpoint_compression: None, diff --git a/crates/auctioneer-config-data/tests/smoke.rs b/crates/auctioneer-config-data/tests/smoke.rs index e41379c..d42ca11 100644 --- a/crates/auctioneer-config-data/tests/smoke.rs +++ b/crates/auctioneer-config-data/tests/smoke.rs @@ -1,7 +1,7 @@ //! Smoke tests for `auctioneer-config-data` codegen output. //! //! Actual baked properties are selected from independently read source definitions. -//! Feature-OFF asserts HTTPS-only metadata; feature-ON retains loopback HTTP evidence. +//! Both feature lanes retain secure compiled defaults; development URLs are explicit settings. #[cfg(test)] mod tests { @@ -91,7 +91,7 @@ mod tests { .unwrap(); for (bid, source) in selected { assert!(bid.uses_https); - assert_eq!(Some(bid.endpoint.as_str()), source["endpoint"].as_str()); + assert_eq!(bid.endpoint.as_deref(), source["endpoint"].as_str()); } } @@ -216,13 +216,42 @@ mod tests { assert_eq!(names, source.keys().map(String::as_str).collect::>()); } + #[test] + fn configurable_publication_requires_explicit_activation_and_destination() { + let selected = source_bidders_with(|row| row.get("endpoint").is_none()).unwrap(); + assert_eq!( + selected.len(), + 1, + "the configured-destination publication is available in both feature lanes" + ); + for (info, source) in selected { + assert_eq!(info.default_enabled, Some(false)); + assert_eq!(source["defaultEnabled"].as_bool(), Some(false)); + assert!(info.endpoint.is_none()); + let encoded = serde_json::to_value(info).unwrap(); + assert!( + encoded + .get("endpoint") + .is_none_or(serde_json::Value::is_null) + ); + assert!( + !info.disabled, + "operator configuration must be able to enable it" + ); + } + } + #[test] #[cfg(not(feature = "insecure-bidders"))] fn feature_off_baked_endpoints_and_syncers_are_https_only() { assert!(!bidders().is_empty()); for bid in bidders() { - assert!(bid.uses_https, "{}", bid.name); - assert!(bid.endpoint.starts_with("https://"), "{}", bid.name); + if let Some(endpoint) = bid.endpoint.as_deref() { + assert!(bid.uses_https, "{}", bid.name); + assert!(endpoint.starts_with("https://"), "{}", bid.name); + } else { + assert_eq!(bid.default_enabled, Some(false)); + } for endpoint in [&bid.user_sync.iframe, &bid.user_sync.redirect] .into_iter() .flatten() @@ -233,18 +262,14 @@ mod tests { } #[test] - #[cfg(feature = "insecure-bidders")] - fn baked_loopback_http_is_present_under_feature() { - let selected = source_bidders_with(|row| { - row.get("endpoint") - .and_then(toml::Value::as_str) - .is_some_and(|endpoint| endpoint.starts_with("http://127.0.0.1:")) - }) - .unwrap(); - for (bid, source) in selected { - assert!(!bid.uses_https); - assert!(bid.endpoint.contains("127.0.0.1")); - assert_eq!(Some(bid.endpoint.as_str()), source["endpoint"].as_str()); + fn development_transport_does_not_supply_an_implicit_loopback_destination() { + assert!(!bidders().is_empty()); + for bid in bidders() { + assert!( + bid.endpoint + .as_deref() + .is_none_or(|endpoint| endpoint.starts_with("https://")) + ); } } diff --git a/crates/auctioneer-config/src/codegen/mod.rs b/crates/auctioneer-config/src/codegen/mod.rs index b30e1b4..3947cfd 100644 --- a/crates/auctioneer-config/src/codegen/mod.rs +++ b/crates/auctioneer-config/src/codegen/mod.rs @@ -177,7 +177,8 @@ pub struct BidderInfoRaw { pub alias_of: Option, #[serde(default, rename = "defaultEnabled")] pub default_enabled: Option, - pub endpoint: String, + #[serde(default)] + pub endpoint: Option, #[serde(default)] pub maintainer: Option, #[serde(default)] @@ -623,7 +624,10 @@ pub fn render_bidder_info(name: &str, info: &BidderInfoRaw) -> BuildResult BuildResult, - pub endpoint: String, + /// Compiled destination template, absent when explicit operator settings are required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, pub uses_https: bool, pub disabled: bool, pub debug_allow: bool, diff --git a/crates/auctioneer-core/src/config/sdk.rs b/crates/auctioneer-core/src/config/sdk.rs index 01c0cf0..959a56b 100644 --- a/crates/auctioneer-core/src/config/sdk.rs +++ b/crates/auctioneer-core/src/config/sdk.rs @@ -238,36 +238,24 @@ mod tests { assert!(!format!("{errors:?}").contains("fake_private_url_canary")); } - #[cfg(not(feature = "insecure-bidders"))] #[test] - fn sdk_validation_explains_missing_owner_features_without_configured_names() { + fn compiled_settings_owners_are_available_without_development_transport() { let mut checked = 0_usize; for declaration in auctioneer_bidder::bidder_declarations() { - let Some(feature) = declaration.required_feature else { - continue; - }; - if declaration.has_settings { + if !declaration.has_settings { continue; } - let Ok(config) = serde_json::from_value::(json!({ + let config = serde_json::from_value::(json!({ "schema_version": 1_u8, - "bidders": {"fake_private_alias_canary": { - "enabled": false, - "implementation": declaration.key, - "settings": {}, + "bidders": {(declaration.key): { + "enabled": false, "implementation": declaration.key, "settings": {}, }}, - })) else { - continue; - }; - let errors = config.validate().unwrap_err(); - let message = errors.to_string(); - assert!(message.contains(declaration.key)); - assert!(message.contains(feature)); - assert!(message.contains("CLI and serving guest")); - assert!(!format!("{errors:?}").contains("fake_private_alias_canary")); + })) + .unwrap(); + config.validate().unwrap(); checked += 1; } - assert!(checked > 0, "exercise disabled typed settings owners"); + assert!(checked > 0, "exercise compiled typed settings owners"); } #[test] diff --git a/crates/auctioneer-core/src/conformance/mod.rs b/crates/auctioneer-core/src/conformance/mod.rs index 1f668a1..8373a46 100644 --- a/crates/auctioneer-core/src/conformance/mod.rs +++ b/crates/auctioneer-core/src/conformance/mod.rs @@ -959,7 +959,7 @@ fn conformance_bidder_info() -> &'static BidderInfo { static INFO: OnceLock = OnceLock::new(); INFO.get_or_init(|| BidderInfo { name: "fake_pp".to_owned(), - endpoint: BIDDER_A.to_owned(), + endpoint: Some(BIDDER_A.to_owned()), uses_https: true, ..BidderInfo::default() }) diff --git a/crates/auctioneer-core/src/handlers/auction.rs b/crates/auctioneer-core/src/handlers/auction.rs index d1fc77b..f0852ce 100644 --- a/crates/auctioneer-core/src/handlers/auction.rs +++ b/crates/auctioneer-core/src/handlers/auction.rs @@ -1171,7 +1171,7 @@ mod tests { .register_with_implementation( Box::leak(Box::new(BidderInfo { name: "fake_shape_fixture".into(), - endpoint: "https://bidder.example/auction".into(), + endpoint: Some("https://bidder.example/auction".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-core/src/handlers/info_bidders.rs b/crates/auctioneer-core/src/handlers/info_bidders.rs index 9c5d6a9..acffa0d 100644 --- a/crates/auctioneer-core/src/handlers/info_bidders.rs +++ b/crates/auctioneer-core/src/handlers/info_bidders.rs @@ -588,7 +588,7 @@ mod tests { fn leak(name: &'static str, disabled: bool) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://x".into(), + endpoint: Some("https://x".into()), uses_https: true, disabled, ..BidderInfo::default() diff --git a/crates/auctioneer-core/src/test_support/bidder_fixture.rs b/crates/auctioneer-core/src/test_support/bidder_fixture.rs index 50c0f14..bb23751 100644 --- a/crates/auctioneer-core/src/test_support/bidder_fixture.rs +++ b/crates/auctioneer-core/src/test_support/bidder_fixture.rs @@ -30,7 +30,7 @@ pub mod fixtures { }; let info = Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://bidder.example/auction".into(), + endpoint: Some("https://bidder.example/auction".into()), uses_https: true, gvl_vendor_id: Some(vendor), capabilities: Capabilities { diff --git a/crates/auctioneer-core/src/test_support/outbound_request.rs b/crates/auctioneer-core/src/test_support/outbound_request.rs index c3ab73f..8169737 100644 --- a/crates/auctioneer-core/src/test_support/outbound_request.rs +++ b/crates/auctioneer-core/src/test_support/outbound_request.rs @@ -39,7 +39,7 @@ pub fn request( alias_of: None, default_enabled: None, name: "fake_fixture".into(), - endpoint: url.clone(), + endpoint: Some(url.clone()), uses_https: true, disabled: false, debug_allow: false, diff --git a/crates/auctioneer-core/tests/auction_config_lifecycle.rs b/crates/auctioneer-core/tests/auction_config_lifecycle.rs index 4291805..1d68133 100644 --- a/crates/auctioneer-core/tests/auction_config_lifecycle.rs +++ b/crates/auctioneer-core/tests/auction_config_lifecycle.rs @@ -50,7 +50,10 @@ mod tests { !info.disabled && info.uses_https && info.debug_allow - && !info.endpoint.contains("{{") + && info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) && info.endpoint_compression.is_none() && info.capabilities.site.contains(&MediaType::Banner) }) @@ -172,7 +175,7 @@ mod tests { let first = parsed(&response); assert_eq!( first["ext"]["debug"]["httpcalls"]["fake_lifecycle_alias"][0]["request"]["endpoint_origin"], - url::Url::parse(&base_a.info.endpoint) + url::Url::parse(base_a.info.endpoint.as_deref().unwrap()) .unwrap() .origin() .ascii_serialization() @@ -180,7 +183,7 @@ mod tests { assert_eq!(app.store().reads(), 1); assert_eq!( fanout.caps.lock().unwrap().as_slice(), - &[(base_a.info.endpoint.clone(), 4096, 4096)] + &[(base_a.info.endpoint.clone().unwrap(), 4096, 4096)] ); let second = parsed(&app.dispatch_with( Method::POST, @@ -194,7 +197,7 @@ mod tests { assert_eq!( fanout.caps.lock().unwrap()[1], ( - base_b.info.endpoint.clone(), + base_b.info.endpoint.clone().unwrap(), limits.max_transport_bytes, limits.max_decoded_bytes ) diff --git a/crates/auctioneer-core/tests/auction_config_retention.rs b/crates/auctioneer-core/tests/auction_config_retention.rs index 957b144..3f26aa9 100644 --- a/crates/auctioneer-core/tests/auction_config_retention.rs +++ b/crates/auctioneer-core/tests/auction_config_retention.rs @@ -106,7 +106,10 @@ rules = [{allow = false, condition = {componentName = ["fake_retention_alias"]}} _deadline: AuctionDeadline, ) -> Result, FanoutInternalError> { assert_eq!(calls.len(), 1); - assert_eq!(calls[0].request.url(), passthrough_info().endpoint); + assert_eq!( + calls[0].request.url(), + passthrough_info().endpoint.as_deref().unwrap() + ); assert_eq!(calls[0].max_transport_bytes, 2048); assert_eq!(calls[0].max_decoded_bytes, 3072); assert!(calls[0].request.covered_imp_ids().eq(["i"])); diff --git a/crates/auctioneer-core/tests/bidder_handler_conformance.rs b/crates/auctioneer-core/tests/bidder_handler_conformance.rs index d0334aa..ee35968 100644 --- a/crates/auctioneer-core/tests/bidder_handler_conformance.rs +++ b/crates/auctioneer-core/tests/bidder_handler_conformance.rs @@ -98,7 +98,7 @@ mod tests { .register_with_implementation( Box::leak(Box::new(BidderInfo { name: "fake_bidder".into(), - endpoint: "https://{{.Host}}/auction".into(), + endpoint: Some("https://{{.Host}}/auction".into()), uses_https: true, endpoint_compression: gzip.then_some(Compression::Gzip), host_allowlist: Some(vec![host.to_owned()]), @@ -284,7 +284,7 @@ mod tests { .register_with_implementation( Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: format!("https://bidder.example/{name}"), + endpoint: Some(format!("https://bidder.example/{name}")), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-core/tests/impression_limit_on_the_auction_path.rs b/crates/auctioneer-core/tests/impression_limit_on_the_auction_path.rs index 6cbd388..5723f47 100644 --- a/crates/auctioneer-core/tests/impression_limit_on_the_auction_path.rs +++ b/crates/auctioneer-core/tests/impression_limit_on_the_auction_path.rs @@ -157,7 +157,10 @@ mod tests { let info = row.info; !info.disabled && info.uses_https - && !info.endpoint.contains("{{") + && info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) && info.endpoint_compression.is_none() && info.capabilities.site.contains(&MediaType::Banner) && row.owner_request["imp"].as_array().is_some_and(|imps| { diff --git a/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs b/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs index 6eb388d..060fe06 100644 --- a/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs +++ b/crates/auctioneer-core/tests/native_bidder_onboarding/cases.rs @@ -1,4 +1,4 @@ -use super::fixtures::{Flow, config, flows, input}; +use super::fixtures::{Flow, config, configured_endpoint_flows, flows, input}; use super::harness::{ Answer, Secrets, assert_disabled, assert_success, boot, send, send_rejected_policy, }; @@ -318,6 +318,95 @@ fn all_priority_privacy_denials_precede_schema_and_host_services(app: &App, coho ); } +fn explicit_endpoint_publications_require_activation_and_destination(app: &App) { + let cohort = configured_endpoint_flows(); + assert_eq!( + cohort.len(), + 1, + "all endpoint-free publications have acceptance" + ); + let mut dispatched = BTreeSet::new(); + for flow in &cohort { + assert!(!flow.default_active()); + assert!(!flow.native_admission); + let requested = [flow]; + let enabled = config(&requested); + + let mut no_settings = enabled.clone(); + for ancestor in &flow.ancestry { + assert!( + no_settings["bidders"] + .as_object_mut() + .unwrap() + .remove(&ancestor.name) + .is_some() + ); + } + let mut default_off = enabled.clone(); + for overrides in default_off["bidders"].as_object_mut().unwrap().values_mut() { + assert!( + overrides + .as_object_mut() + .unwrap() + .remove("enabled") + .is_some() + ); + } + let mut explicitly_disabled = enabled.clone(); + explicitly_disabled["bidders"][flow.name()]["enabled"] = json!(false); + for runtime in [&no_settings, &default_off, &explicitly_disabled] { + let (answer, secrets) = dispatch(app, &requested, runtime); + assert_disabled(&answer, flow); + assert!(secrets.reads.lock().unwrap().is_empty()); + } + + let mut missing_endpoint = enabled.clone(); + for ancestor in &flow.ancestry { + let row = missing_endpoint["bidders"][&ancestor.name] + .as_object_mut() + .unwrap(); + if ancestor.name == flow.name() { + assert!( + row.get("settings") + .and_then(|settings| settings.get("endpoint")) + .is_some(), + "the enabled control supplies an explicit endpoint" + ); + } + row.remove("implementation"); + row.remove("settings"); + } + let rejected_secrets = Secrets::new(&requested, false); + let rejected = send_rejected_policy( + app, + &requested, + &input(&requested), + &missing_endpoint, + &rejected_secrets, + PolicyValidationError::Bidder(BidderConfigurationError::Bidder(BidderConfigError { + field: "endpoint", + reason: "required when enabled", + })), + ); + assert_eq!(rejected.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(rejected.wire, json!("Malformed host configuration")); + assert!(rejected.calls.is_empty()); + assert_eq!(rejected.received, 0); + assert!(rejected_secrets.reads.lock().unwrap().is_empty()); + + let (answer, secrets) = dispatch(app, &requested, &enabled); + assert_success(&answer, &requested); + assert_no_errors(&answer); + assert!(secrets.reads.lock().unwrap().is_empty()); + dispatched.extend(answer.calls.iter().map(|call| call.owner.clone())); + } + assert_eq!( + dispatched, + cohort.iter().map(|flow| flow.name().to_owned()).collect() + ); + assert_eq!(dispatched.len(), 1); +} + #[test] fn all_frozen_native_flows_use_generated_app_dispatch_and_fresh_activation_snapshots() { let app = boot(); @@ -339,4 +428,5 @@ fn all_frozen_native_flows_use_generated_app_dispatch_and_fresh_activation_snaps all_priority_privacy_denials_precede_schema_and_host_services(&app, &cohort); super::privacy::all_priority_outbound_privacy(&app, &cohort); super::sync::applicable_templates_use_signed_generated_app_callbacks(&app, &cohort); + explicit_endpoint_publications_require_activation_and_destination(&app); } diff --git a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs index c451c11..9f63f37 100644 --- a/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs +++ b/crates/auctioneer-core/tests/native_bidder_onboarding/fixtures.rs @@ -231,6 +231,15 @@ pub(super) fn flows() -> Vec { .collect() } +pub(super) fn configured_endpoint_flows() -> Vec { + let bidder_root = owner_root(); + serving_catalog::configured_endpoint_publications() + .iter() + .enumerate() + .map(|(ordinal, row)| flow_from_publication(&bidder_root, row, ordinal)) + .collect() +} + fn flow_from_member( bidder_root: &Path, published: &[ServingBidder], @@ -242,6 +251,22 @@ fn flow_from_member( .find(|row| row.name() == member.name) .expect("every priority flow is published"); assert_eq!(row.implementation, member.implementation); + let flow = flow_from_publication(bidder_root, row, ordinal); + let base = flow + .ancestry + .last() + .expect("flow has implementation ancestry"); + assert!( + native_schema_available(&base.name), + "every selected schema owner is available on the funded native path" + ); + if let Some(expected_base) = member.base { + assert_eq!(base.name, expected_base); + } + flow +} + +fn flow_from_publication(bidder_root: &Path, row: &ServingBidder, ordinal: usize) -> Flow { let mut ancestry = Vec::new(); let mut info = row.info; loop { @@ -259,10 +284,6 @@ fn flow_from_member( } } let native_admission = native_schema_required(&info.name); - assert!( - native_schema_available(&info.name), - "every selected schema owner is available on the funded native path" - ); if native_admission { assert!( ancestry @@ -271,9 +292,6 @@ fn flow_from_member( "new controlled-native flows are inactive without runtime opt-in" ); } - if let Some(base) = member.base { - assert_eq!(info.name, base); - } let source_root = bidder_root.join("src/bidders").canonicalize().unwrap(); let owner = source_root.join(row.implementation).canonicalize().unwrap(); assert_eq!(owner.parent(), Some(source_root.as_path())); @@ -285,7 +303,7 @@ fn flow_from_member( .expect("valid owner privacy facts") }); assert_eq!(fixture.schema_version, 1); - assert_eq!(fixture.implementation, member.implementation); + assert_eq!(fixture.implementation, row.implementation); let case = fixture .cases .into_iter() @@ -308,7 +326,7 @@ fn flow_from_member( ); assert_eq!( recorded_fields["implementation"].as_str(), - Some(member.implementation.as_str()), + Some(row.implementation), "recorded settings belong to their fixture owner", ); assert!(recorded_fields["values"].is_object()); @@ -543,9 +561,13 @@ pub(super) fn expected_body(flow: &Flow, ordinal: usize, input: &Value) -> Value } /// The fixture supplies protocol suffixes and macro values; published metadata -/// supplies the real HTTPS destination. Synthetic factory endpoint queries are -/// not production protocol additions. +/// supplies the real HTTPS destination. Endpoint-free publications instead use +/// the independent literal expected for the configured-endpoint acceptance row. +/// Synthetic factory endpoint queries are not production protocol additions. pub(super) fn expected_url(flow: &Flow, ordinal: usize) -> String { + let Some(mut endpoint) = flow.info.endpoint.clone() else { + return "https://configured-bidder.example.test:8443/openrtb?route=onboarding".into(); + }; let outbound = &flow.case.outbound[ordinal]; let mut template = flow.case.context["endpoint_template"].as_str().unwrap(); let mut remainder = outbound.url.as_str(); @@ -573,7 +595,6 @@ pub(super) fn expected_url(flow: &Flow, ordinal: usize) -> String { let suffix = remainder .strip_prefix(template) .expect("fixture endpoint suffix"); - let mut endpoint = flow.info.endpoint.clone(); if endpoint.contains("{{.Host}}") && !bindings.contains_key("Host") { let region = flow .settings() @@ -736,6 +757,16 @@ pub(super) fn config(flows: &[&Flow]) -> Value { row["implementation"] = recorded["implementation"].clone(); row["settings"] = recorded["values"].clone(); } + if flow.info.endpoint.is_none() { + let row = bidders.get_mut(flow.name()).unwrap(); + let binding = auctioneer_config_data::bidder_implementation_bindings() + .iter() + .find(|binding| binding.bidder == flow.name()) + .expect("published endpoint-free implementation binding"); + row["implementation"] = json!(binding.implementation); + row["settings"]["endpoint"] = + json!("https://configured-bidder.example.test:8443/openrtb?route=onboarding"); + } } json!({ "schema_version": 1_u64, diff --git a/crates/auctioneer-core/tests/node_budget_on_the_auction_path.rs b/crates/auctioneer-core/tests/node_budget_on_the_auction_path.rs index 810020b..8bc1c0e 100644 --- a/crates/auctioneer-core/tests/node_budget_on_the_auction_path.rs +++ b/crates/auctioneer-core/tests/node_budget_on_the_auction_path.rs @@ -232,7 +232,10 @@ mod tests { let info = row.info; !info.disabled && info.uses_https - && !info.endpoint.contains("{{") + && info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) && info.capabilities.site.contains(&MediaType::Banner) }) .min_by_key(ServingBidder::name) diff --git a/crates/auctioneer-core/tests/privacy_outbound.rs b/crates/auctioneer-core/tests/privacy_outbound.rs index c7ce927..5f72dc4 100644 --- a/crates/auctioneer-core/tests/privacy_outbound.rs +++ b/crates/auctioneer-core/tests/privacy_outbound.rs @@ -272,7 +272,7 @@ mod tests { let metadata = row.info; let requests: Vec<_> = outbound .iter() - .filter(|request| request.url() == metadata.endpoint) + .filter(|request| request.url() == metadata.endpoint.as_deref().unwrap()) .collect(); assert_eq!(requests.len(), 1, "exactly one send for {bidder}"); assert_eq!(requests[0].method(), "POST"); @@ -649,7 +649,7 @@ mod tests { } assert_eq!(body["device"]["ip"], "8.8.4.0"); assert_eq!(body["device"]["ipv6"], "2001:4860:4860::"); - let endpoint = &row.info.endpoint; + let endpoint = row.info.endpoint.as_deref().unwrap(); let request = outbound .iter() .find(|request| request.url() == endpoint) diff --git a/crates/auctioneer-core/tests/privacy_wire_signal_400.rs b/crates/auctioneer-core/tests/privacy_wire_signal_400.rs index b805c02..dcee2ac 100644 --- a/crates/auctioneer-core/tests/privacy_wire_signal_400.rs +++ b/crates/auctioneer-core/tests/privacy_wire_signal_400.rs @@ -87,7 +87,10 @@ mod tests { let info = row.info; !info.disabled && info.uses_https - && !info.endpoint.contains("{{") + && info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) && info.endpoint_compression.is_none() && info.capabilities.site.contains(&MediaType::Banner) && row.owner_expected_request_body.as_ref() == Some(&row.owner_request) diff --git a/crates/auctioneer-core/tests/response_gdpr_applies.rs b/crates/auctioneer-core/tests/response_gdpr_applies.rs index 4d50a00..1284cd1 100644 --- a/crates/auctioneer-core/tests/response_gdpr_applies.rs +++ b/crates/auctioneer-core/tests/response_gdpr_applies.rs @@ -504,7 +504,7 @@ mod tests { let mut registry = BidderRegistry::new(); let info = Box::leak(Box::new(BidderInfo { name: OUTBOUND_BIDDER.into(), - endpoint: "https://bidder.example/auction".into(), + endpoint: Some("https://bidder.example/auction".into()), uses_https: true, capabilities: Capabilities { site: vec![MediaType::Banner], diff --git a/crates/auctioneer-core/tests/response_mid_flight_timeout.rs b/crates/auctioneer-core/tests/response_mid_flight_timeout.rs index 3d18444..125570e 100644 --- a/crates/auctioneer-core/tests/response_mid_flight_timeout.rs +++ b/crates/auctioneer-core/tests/response_mid_flight_timeout.rs @@ -118,7 +118,13 @@ mod tests { let mut rows: Vec<_> = default_active_site_media(&[MediaType::Banner]) .into_iter() .filter(|row| { - !row.info.disabled && row.info.uses_https && !row.info.endpoint.contains("{{") + !row.info.disabled + && row.info.uses_https + && row + .info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) }) .collect(); rows.sort_by_key(ServingBidder::name); diff --git a/crates/auctioneer-core/tests/response_oversize_response.rs b/crates/auctioneer-core/tests/response_oversize_response.rs index 1e6b45a..cba0090 100644 --- a/crates/auctioneer-core/tests/response_oversize_response.rs +++ b/crates/auctioneer-core/tests/response_oversize_response.rs @@ -110,7 +110,14 @@ mod tests { ROLES.get_or_init(|| { let mut candidates = serving_catalog::default_active_site_media(&[MediaType::Banner]) .into_iter() - .filter(|row| !row.info.disabled && !row.info.endpoint.contains("{{")); + .filter(|row| { + !row.info.disabled + && row + .info + .endpoint + .as_deref() + .is_some_and(|endpoint| !endpoint.contains("{{")) + }); let first = candidates.next().unwrap(); let second = candidates .find(|row| row.info.endpoint != first.info.endpoint) @@ -598,36 +605,37 @@ mod tests { Ok(calls .iter() .map(|call| { - let result = if call.request.url() == roles()[0].info.endpoint { - // The origin is arbitrary here on purpose: the - // wire layer must render code 4 / statuscode 100 - // for EVERY origin, so the row picks one and the - // origin-blindness assertion below covers the - // rest. - Err(FanoutError::OversizeResponse(OversizeOrigin::HeaderCount)) - } else { - let bids: Vec = call - .request - .covered_imp_ids() - .map(|imp_id| { - format!( - "{{\"id\":\"bid-{imp_id}\",\"impid\":\"{imp_id}\",\ + let result = + if call.request.url() == roles()[0].info.endpoint.as_deref().unwrap() { + // The origin is arbitrary here on purpose: the + // wire layer must render code 4 / statuscode 100 + // for EVERY origin, so the row picks one and the + // origin-blindness assertion below covers the + // rest. + Err(FanoutError::OversizeResponse(OversizeOrigin::HeaderCount)) + } else { + let bids: Vec = call + .request + .covered_imp_ids() + .map(|imp_id| { + format!( + "{{\"id\":\"bid-{imp_id}\",\"impid\":\"{imp_id}\",\ \"price\":1.5,\"w\":300,\"h\":250,\ \"crid\":\"creative-{imp_id}\"}}" - ) - }) - .collect(); - Ok(BidderHttpResponse::new( - 200, - vec![], - format!( - "{{\"id\":\"r\",\"seatbid\":[{{\"seat\":\"s\",\"bid\":[{}]}}],\ + ) + }) + .collect(); + Ok(BidderHttpResponse::new( + 200, + vec![], + format!( + "{{\"id\":\"r\",\"seatbid\":[{{\"seat\":\"s\",\"bid\":[{}]}}],\ \"cur\":\"USD\"}}", - bids.join(","), - ) - .into_bytes(), - )) - }; + bids.join(","), + ) + .into_bytes(), + )) + }; BidderCallResult { result, elapsed: Duration::from_millis(10), diff --git a/crates/auctioneer-core/tests/response_slot_classification.rs b/crates/auctioneer-core/tests/response_slot_classification.rs index b6fa31c..dcaa550 100644 --- a/crates/auctioneer-core/tests/response_slot_classification.rs +++ b/crates/auctioneer-core/tests/response_slot_classification.rs @@ -214,7 +214,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_pp".into(), - endpoint: "https://bidder.example/rtb".into(), + endpoint: Some("https://bidder.example/rtb".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/src/debug/capture.rs b/crates/auctioneer-exchange/src/debug/capture.rs index 69ec1af..997a3a3 100644 --- a/crates/auctioneer-exchange/src/debug/capture.rs +++ b/crates/auctioneer-exchange/src/debug/capture.rs @@ -497,7 +497,7 @@ mod tests { fn outbound_request() -> BidderHttpRequest { let endpoint = "https://bidder.example/private?uid=SECRET_URL"; let bidder = BidderInfo { - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), ..BidderInfo::default() }; let info = BidderRequestInfo::new( diff --git a/crates/auctioneer-exchange/src/limits.rs b/crates/auctioneer-exchange/src/limits.rs index 75add69..16bb888 100644 --- a/crates/auctioneer-exchange/src/limits.rs +++ b/crates/auctioneer-exchange/src/limits.rs @@ -138,7 +138,7 @@ mod tests { ); let bidder = BidderInfo { name: "fake_fixture".into(), - endpoint: url.clone(), + endpoint: Some(url.clone()), uses_https: true, ..BidderInfo::default() }; diff --git a/crates/auctioneer-exchange/src/run/mod.rs b/crates/auctioneer-exchange/src/run/mod.rs index cbd2885..05d0d2d 100644 --- a/crates/auctioneer-exchange/src/run/mod.rs +++ b/crates/auctioneer-exchange/src/run/mod.rs @@ -1037,10 +1037,18 @@ fn bind_bidder_configuration<'a>( .map(|binding| binding.implementation); info.set_settings(implementation, requested, base)?; let endpoint = adapter.configured_endpoint(info.settings(), info.base_settings())?; - if let Some(selected) = endpoint { - info.endpoint_template = selected.as_str(); - info.endpoint_macros = &[]; - info.host_allowlist = None; + match endpoint { + Some(selected) => { + info.endpoint_template = selected.as_str(); + info.endpoint_macros = &[]; + info.host_allowlist = None; + } + None if info.endpoint_template.is_empty() => { + return Err(auctioneer_bidder::BidderError::failed_to_request_bids( + "configured bidder endpoint required", + )); + } + None => {} } // The selected URL already lives in `info`; carry only its policy choice // across credential lookup instead of retaining a second URL borrow. @@ -1071,7 +1079,7 @@ async fn run_make_requests( pbr.base.clone(), &ctx.account.id, publisher_id, - &info.endpoint, + info.endpoint.as_deref().unwrap_or_default(), ¯os, allow.as_deref(), gpc, diff --git a/crates/auctioneer-exchange/src/validate_endpoint.rs b/crates/auctioneer-exchange/src/validate_endpoint.rs index 5850e76..562bc55 100644 --- a/crates/auctioneer-exchange/src/validate_endpoint.rs +++ b/crates/auctioneer-exchange/src/validate_endpoint.rs @@ -13,7 +13,11 @@ use std::net::{Ipv4Addr, Ipv6Addr}; /// # Errors /// Returns code 5 with fixed category text, never raw URL or parser details. pub fn validate_endpoint(url: &str, info: &BidderInfo) -> Result<(), BidderError> { - validate_endpoint_policy(url, &info.endpoint, info.host_allowlist.as_deref()) + let template = info + .endpoint + .as_deref() + .ok_or_else(|| public_error(EndpointRejectReason::MalformedUrl))?; + validate_endpoint_policy(url, template, info.host_allowlist.as_deref()) } fn validate_endpoint_policy( @@ -391,7 +395,7 @@ mod tests { fn bound_metadata_keeps_the_declared_host_allowlist() { let currency = Vec::new(); let info = BidderInfo { - endpoint: "https://{{.Host}}/auction".into(), + endpoint: Some("https://{{.Host}}/auction".into()), host_allowlist: Some(vec!["east.example.test".into(), "west.example.test".into()]), ..refusing_info() }; @@ -427,7 +431,7 @@ mod tests { fn refusing_info() -> BidderInfo { BidderInfo { name: "fake_ax".into(), - endpoint: "https://hb.example.test/openrtb".into(), + endpoint: Some("https://hb.example.test/openrtb".into()), uses_https: true, host_allowlist: None, ..BidderInfo::default() diff --git a/crates/auctioneer-exchange/tests/auction_debug/main.rs b/crates/auctioneer-exchange/tests/auction_debug/main.rs index 3345d7d..0e8c999 100644 --- a/crates/auctioneer-exchange/tests/auction_debug/main.rs +++ b/crates/auctioneer-exchange/tests/auction_debug/main.rs @@ -390,7 +390,9 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://bidder.example.test/private?token=SECRET".into(), + endpoint: Some( + "https://bidder.example.test/private?token=SECRET".into(), + ), uses_https: true, debug_allow, bidder_timeout_ms: Some(10), diff --git a/crates/auctioneer-exchange/tests/auction_support/mod.rs b/crates/auctioneer-exchange/tests/auction_support/mod.rs index 99be93d..83e36e7 100644 --- a/crates/auctioneer-exchange/tests/auction_support/mod.rs +++ b/crates/auctioneer-exchange/tests/auction_support/mod.rs @@ -304,7 +304,7 @@ impl Harness { for (name, gpp_supported) in [("fake_bb", false), ("fake_ccc", true), ("fake_aaa", false)] { let mut info = BidderInfo { name: name.into(), - endpoint: "https://bidder.example.test/openrtb".into(), + endpoint: Some("https://bidder.example.test/openrtb".into()), uses_https: true, disabled: self.baked_disabled.contains(name), ..BidderInfo::default() diff --git a/crates/auctioneer-exchange/tests/bid_impression_correlation.rs b/crates/auctioneer-exchange/tests/bid_impression_correlation.rs index cc5028a..286223f 100644 --- a/crates/auctioneer-exchange/tests/bid_impression_correlation.rs +++ b/crates/auctioneer-exchange/tests/bid_impression_correlation.rs @@ -121,7 +121,7 @@ mod tests { fn info(name: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://bids.example.test/auction".into(), + endpoint: Some("https://bids.example.test/auction".into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/bidder_call_correlation.rs b/crates/auctioneer-exchange/tests/bidder_call_correlation.rs index 2e7e805..0029f5a 100644 --- a/crates/auctioneer-exchange/tests/bidder_call_correlation.rs +++ b/crates/auctioneer-exchange/tests/bidder_call_correlation.rs @@ -293,7 +293,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: BIDDER.into(), - endpoint: "https://bidder.example.test/auction".into(), + endpoint: Some("https://bidder.example.test/auction".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/bidder_response_node_budget.rs b/crates/auctioneer-exchange/tests/bidder_response_node_budget.rs index 6d9923a..4f011e1 100644 --- a/crates/auctioneer-exchange/tests/bidder_response_node_budget.rs +++ b/crates/auctioneer-exchange/tests/bidder_response_node_budget.rs @@ -116,7 +116,7 @@ mod tests { fn leak_info() -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: "fake_bb".into(), - endpoint: "https://demo.example.test/openrtb".into(), + endpoint: Some("https://demo.example.test/openrtb".into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/bidder_services.rs b/crates/auctioneer-exchange/tests/bidder_services.rs index cc400c3..5a076f1 100644 --- a/crates/auctioneer-exchange/tests/bidder_services.rs +++ b/crates/auctioneer-exchange/tests/bidder_services.rs @@ -194,7 +194,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: format!("https://bidder.example.test/{name}"), + endpoint: Some(format!("https://bidder.example.test/{name}")), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/clone_bounds/main.rs b/crates/auctioneer-exchange/tests/clone_bounds/main.rs index 619e3fe..38745aa 100644 --- a/crates/auctioneer-exchange/tests/clone_bounds/main.rs +++ b/crates/auctioneer-exchange/tests/clone_bounds/main.rs @@ -115,7 +115,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_bb".into(), - endpoint: "https://bidder.example.test/".into(), + endpoint: Some("https://bidder.example.test/".into()), ..BidderInfo::default() })), Box::new(NoRequests(Arc::clone(calls))), diff --git a/crates/auctioneer-exchange/tests/code12_attribution.rs b/crates/auctioneer-exchange/tests/code12_attribution.rs index 938bf25..6f89100 100644 --- a/crates/auctioneer-exchange/tests/code12_attribution.rs +++ b/crates/auctioneer-exchange/tests/code12_attribution.rs @@ -95,7 +95,7 @@ mod tests { fn leak_info(name: &'static str, endpoint: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/config_overlay.rs b/crates/auctioneer-exchange/tests/config_overlay.rs index bb769f6..88d6575 100644 --- a/crates/auctioneer-exchange/tests/config_overlay.rs +++ b/crates/auctioneer-exchange/tests/config_overlay.rs @@ -315,7 +315,7 @@ mod tests { fn leak(name: &'static str, endpoint: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), uses_https: true, capabilities: Capabilities { site: vec![MediaType::Banner], @@ -1205,7 +1205,7 @@ mod tests { for &(name, disabled) in bidders { let info: &'static BidderInfo = Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://demo.example.test/openrtb".into(), + endpoint: Some("https://demo.example.test/openrtb".into()), uses_https: true, disabled, capabilities: Capabilities { diff --git a/crates/auctioneer-exchange/tests/construction_bound.rs b/crates/auctioneer-exchange/tests/construction_bound.rs index f9cb4da..e288d4b 100644 --- a/crates/auctioneer-exchange/tests/construction_bound.rs +++ b/crates/auctioneer-exchange/tests/construction_bound.rs @@ -326,7 +326,7 @@ mod tests { fn leak_info(name: String, endpoint: String) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name, - endpoint, + endpoint: Some(endpoint), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/construction_deadline.rs b/crates/auctioneer-exchange/tests/construction_deadline.rs index 24aa22f..0d7ab01 100644 --- a/crates/auctioneer-exchange/tests/construction_deadline.rs +++ b/crates/auctioneer-exchange/tests/construction_deadline.rs @@ -163,7 +163,7 @@ mod tests { .into_iter() .map(|name| BidderInfo { name: name.into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), uses_https: true, ..BidderInfo::default() }) @@ -172,7 +172,7 @@ mod tests { static GZIP_INFO: LazyLock = LazyLock::new(|| BidderInfo { name: "fake_gzip".into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), uses_https: true, endpoint_compression: Some(Compression::Gzip), ..BidderInfo::default() @@ -180,7 +180,7 @@ mod tests { static EMPTY_INFO: LazyLock = LazyLock::new(|| BidderInfo { name: "fake_z0".into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), uses_https: true, ..BidderInfo::default() }); diff --git a/crates/auctioneer-exchange/tests/debug_allocation/mod.rs b/crates/auctioneer-exchange/tests/debug_allocation/mod.rs index e37ee47..bf7dc45 100644 --- a/crates/auctioneer-exchange/tests/debug_allocation/mod.rs +++ b/crates/auctioneer-exchange/tests/debug_allocation/mod.rs @@ -196,7 +196,7 @@ pub fn deadline(clock: &FakeClock) -> MonotonicInstant { pub fn outbound(value: &Value, headers: bool) -> BidderHttpRequest { let endpoint = "https://bidder.example/PATH_CANARY?token=URL_CANARY#FRAGMENT_CANARY"; let bidder = BidderInfo { - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), ..BidderInfo::default() }; let info = BidderRequestInfo::new( @@ -393,7 +393,7 @@ impl Engine { .register( Box::leak(Box::new(BidderInfo { name: "fake_bb".into(), - endpoint: "https://bidder.example/private?token=ENGINE_URL_CANARY".into(), + endpoint: Some("https://bidder.example/private?token=ENGINE_URL_CANARY".into()), debug_allow: base_allow, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/determinism.rs b/crates/auctioneer-exchange/tests/determinism.rs index f83fde0..12aec9d 100644 --- a/crates/auctioneer-exchange/tests/determinism.rs +++ b/crates/auctioneer-exchange/tests/determinism.rs @@ -80,7 +80,7 @@ mod tests { fn leak(name: &'static str, endpoint: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/disclosure_policy_diff.rs b/crates/auctioneer-exchange/tests/disclosure_policy_diff.rs index f2ed0b1..15c5a1c 100644 --- a/crates/auctioneer-exchange/tests/disclosure_policy_diff.rs +++ b/crates/auctioneer-exchange/tests/disclosure_policy_diff.rs @@ -754,12 +754,15 @@ mod tests { const LABEL: &str = "fake_bb-capped-serialization"; let config = fake_bidder::info(); assert_eq!(config.name, "fake_bb", "synthetic fixture identity"); + let Some(endpoint) = config.endpoint.as_deref() else { + panic!("synthetic fixture requires a compiled endpoint"); + }; let info = BidderRequestInfo::new( BidderName::new("fake_bb"), BidderName::new("fake_bb"), "synthetic-account", None, - &config.endpoint, + endpoint, &[], None, None, diff --git a/crates/auctioneer-exchange/tests/expired_tmax_diagnostics.rs b/crates/auctioneer-exchange/tests/expired_tmax_diagnostics.rs index 4ba5126..f7776c0 100644 --- a/crates/auctioneer-exchange/tests/expired_tmax_diagnostics.rs +++ b/crates/auctioneer-exchange/tests/expired_tmax_diagnostics.rs @@ -35,7 +35,7 @@ mod tests { static GENERIC: LazyLock = LazyLock::new(|| BidderInfo { name: "fake_bb".into(), - endpoint: "https://demo.example.test/fake_bb".into(), + endpoint: Some("https://demo.example.test/fake_bb".into()), uses_https: true, ..BidderInfo::default() }); diff --git a/crates/auctioneer-exchange/tests/outbound_construction.rs b/crates/auctioneer-exchange/tests/outbound_construction.rs index 61b6a1a..a553c3f 100644 --- a/crates/auctioneer-exchange/tests/outbound_construction.rs +++ b/crates/auctioneer-exchange/tests/outbound_construction.rs @@ -190,7 +190,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), uses_https, ..BidderInfo::default() })), @@ -300,7 +300,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_first".into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/privacy_enforcement.rs b/crates/auctioneer-exchange/tests/privacy_enforcement.rs index fad186c..0e92968 100644 --- a/crates/auctioneer-exchange/tests/privacy_enforcement.rs +++ b/crates/auctioneer-exchange/tests/privacy_enforcement.rs @@ -109,7 +109,7 @@ mod tests { fn leak_info(name: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: "https://demo.example.test/openrtb".into(), + endpoint: Some("https://demo.example.test/openrtb".into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/projection_scrub_bound.rs b/crates/auctioneer-exchange/tests/projection_scrub_bound.rs index d169759..da51872 100644 --- a/crates/auctioneer-exchange/tests/projection_scrub_bound.rs +++ b/crates/auctioneer-exchange/tests/projection_scrub_bound.rs @@ -374,7 +374,7 @@ mod tests { scope.base.clone(), "acct", None, - &bidder_info.endpoint, + bidder_info.endpoint.as_deref().unwrap(), &[], None, None, diff --git a/crates/auctioneer-exchange/tests/request_provenance.rs b/crates/auctioneer-exchange/tests/request_provenance.rs index 84b19df..105c18a 100644 --- a/crates/auctioneer-exchange/tests/request_provenance.rs +++ b/crates/auctioneer-exchange/tests/request_provenance.rs @@ -259,7 +259,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: BASE.into(), - endpoint: "https://bidder.example.test/auction".into(), + endpoint: Some("https://bidder.example.test/auction".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/response_charge.rs b/crates/auctioneer-exchange/tests/response_charge.rs index 5f3902f..eae4e7a 100644 --- a/crates/auctioneer-exchange/tests/response_charge.rs +++ b/crates/auctioneer-exchange/tests/response_charge.rs @@ -213,7 +213,7 @@ mod tests { if bidder != "fake_bb" { input["ext"]["prebid"]["aliases"] = json!({bidder.clone():"fake_bb"}); } let req = prepare(serde_json::from_value(input).unwrap(), &RequestHeaders::default(), &PrepareLimits {max_impressions:100, ..PrepareLimits::default()}).unwrap(); let mut registry = auctioneer_bidder::BidderRegistry::new(); - registry.register(Box::leak(Box::new(BidderInfo {name:"fake_bb".into(), endpoint:"https://example.test/bid".into(), ..BidderInfo::default()})), Box::new(FakeBidder::default())).expect("unique test bidder"); + registry.register(Box::leak(Box::new(BidderInfo {name:"fake_bb".into(), endpoint:Some("https://example.test/bid".into()), ..BidderInfo::default()})), Box::new(FakeBidder::default())).expect("unique test bidder"); let mut host = auctioneer_config_data::host_default().clone(); host.gdpr.enabled = false; let plan = AuctionPlan::new( @@ -315,7 +315,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_bb".into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), ..BidderInfo::default() })), Box::new(FakeBidder::default()), @@ -491,9 +491,10 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_bb____".into(), - endpoint: + endpoint: Some( "https://bidder.example.test/openrtb?fixture=browser_coverage_controls" .into(), + ), ..BidderInfo::default() })), Box::new(FakeBidder { @@ -1356,7 +1357,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_bb".into(), - endpoint: "https://example.test/bid".into(), + endpoint: Some("https://example.test/bid".into()), ..BidderInfo::default() })), Box::new(FakeBidder::default()), diff --git a/crates/auctioneer-exchange/tests/run_auction/main.rs b/crates/auctioneer-exchange/tests/run_auction/main.rs index 3c0fe09..400c92c 100644 --- a/crates/auctioneer-exchange/tests/run_auction/main.rs +++ b/crates/auctioneer-exchange/tests/run_auction/main.rs @@ -153,81 +153,99 @@ mod tests { fn configured_endpoint_reaches_the_real_auction_sink_without_request_overrides() { const COMPILED: &str = "https://compiled.example.test/original"; const CONFIGURED: &str = "https://configured.example.test:8443/auction?route=one"; - for selected in [ - None, - Some(CONFIGURED), - Some("http://unsafe.example.test/bid"), - ] { - let mut registry = BidderRegistry::new(); - registry - .register( - leak_info("fake_bb", COMPILED), - Box::new(FakeBidder { - configured_endpoint: selected, - ..FakeBidder::default() - }), + static PREFLIGHT: AtomicUsize = AtomicUsize::new(0); + static CONSTRUCTIONS: AtomicUsize = AtomicUsize::new(0); + for compiled in [Some(COMPILED), None] { + for selected in [ + None, + Some(CONFIGURED), + Some("http://unsafe.example.test/bid"), + ] { + PREFLIGHT.store(0, Ordering::SeqCst); + CONSTRUCTIONS.store(0, Ordering::SeqCst); + let mut metadata = leak_info("fake_bb", COMPILED).clone(); + metadata.endpoint = compiled.map(str::to_owned); + let mut registry = BidderRegistry::new(); + registry + .register( + Box::leak(Box::new(metadata)), + Box::new(FakeBidder { + configured_endpoint: selected, + credential_preflight_observer: Some(&|_, _| { + PREFLIGHT.fetch_add(1, Ordering::SeqCst); + }), + construction_observer: Some(&|_| { + CONSTRUCTIONS.fetch_add(1, Ordering::SeqCst); + }), + ..FakeBidder::default() + }), + ) + .unwrap(); + let request = prepare( + serde_json::from_value(json!({ + "id": "endpoint-test", "site": {"page": "https://publisher.example"}, + "regs": {"gdpr": 0_u8}, + "imp": [{"id": "i", "banner": {"w": 300_i32, "h": 250_i32}, + "ext": {"bidder": {"fake_bb": { + "endpoint": "https://forged.example.test/bid" + }}}}] + })) + .unwrap(), + &RequestHeaders::default(), + &IMP_CAP_NOT_UNDER_TEST, ) .unwrap(); - let request = prepare( - serde_json::from_value(json!({ - "id": "endpoint-test", "site": {"page": "https://publisher.example"}, - "regs": {"gdpr": 0_u8}, - "imp": [{"id": "i", "banner": {"w": 300_i32, "h": 250_i32}, - "ext": {"bidder": {"fake_bb": { - "endpoint": "https://forged.example.test/bid" - }}}}] - })) - .unwrap(), - &RequestHeaders::default(), - &IMP_CAP_NOT_UNDER_TEST, - ) - .unwrap(); - let before = serde_json::to_value(&*request).unwrap(); - let host = host_with_caps(1000, 2000); - let config = AccountConfig::default(); - let account = ResolvedAccount { - id: "publisher".into(), - source: AccountSource::DefaultFallback, - config: &config, - }; - let fanout = EndpointFanout::default(); - let clock = FakeClock::new(time::OffsetDateTime::UNIX_EPOCH); - let rng = SeededRng::new(42); - let ctx = AuctionContext::with_request_start( - clock.now_monotonic(), - &host, - &account, - ®istry, - &fanout, - &clock, - &rng, - GvlStore::baked(), - Gcl::baked(), - clock.now(), - false, - ); - let plan = AuctionPlan::new( - ResolvedAliases::empty(), - BTreeSet::new(), - response_budgets(ctx.bidders, ctx.host), - BTreeMap::new(), - ); - let mut audit = DisclosureAudit::default(); - let response = block_on(run_auction(&request, &ctx, &plan, &mut audit)) - .unwrap() - .into_bid_response() - .unwrap(); - let seen = fanout.0.lock().unwrap(); - if selected == Some("http://unsafe.example.test/bid") { - assert!(seen.is_empty()); - assert_eq!( - response.ext.as_ref().unwrap()["errors"]["fake_bb"][0]["code"], - 5_u32 + let before = serde_json::to_value(&*request).unwrap(); + let host = host_with_caps(1000, 2000); + let config = AccountConfig::default(); + let account = ResolvedAccount { + id: "publisher".into(), + source: AccountSource::DefaultFallback, + config: &config, + }; + let fanout = EndpointFanout::default(); + let clock = FakeClock::new(time::OffsetDateTime::UNIX_EPOCH); + let rng = SeededRng::new(42); + let ctx = AuctionContext::with_request_start( + clock.now_monotonic(), + &host, + &account, + ®istry, + &fanout, + &clock, + &rng, + GvlStore::baked(), + Gcl::baked(), + clock.now(), + false, ); - } else { - assert_eq!(seen.as_slice(), &[selected.unwrap_or(COMPILED)]); + let plan = AuctionPlan::new( + ResolvedAliases::empty(), + BTreeSet::new(), + response_budgets(ctx.bidders, ctx.host), + BTreeMap::new(), + ); + let mut audit = DisclosureAudit::default(); + let response = block_on(run_auction(&request, &ctx, &plan, &mut audit)) + .unwrap() + .into_bid_response() + .unwrap(); + let seen = fanout.0.lock().unwrap(); + let refused = selected == Some("http://unsafe.example.test/bid") + || (selected.is_none() && compiled.is_none()); + assert_eq!(PREFLIGHT.load(Ordering::SeqCst), usize::from(!refused)); + assert_eq!(CONSTRUCTIONS.load(Ordering::SeqCst), usize::from(!refused)); + if refused { + assert!(seen.is_empty()); + assert_eq!( + response.ext.as_ref().unwrap()["errors"]["fake_bb"][0]["code"], + 5_u32 + ); + } else { + assert_eq!(seen.as_slice(), &[selected.or(compiled).unwrap()]); + } + assert_eq!(serde_json::to_value(&*request).unwrap(), before); } - assert_eq!(serde_json::to_value(&*request).unwrap(), before); } } @@ -850,7 +868,7 @@ mod tests { fn leak_info(name: &'static str, endpoint: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), uses_https: true, ..BidderInfo::default() })) diff --git a/crates/auctioneer-exchange/tests/sampling_nbr.rs b/crates/auctioneer-exchange/tests/sampling_nbr.rs index 75dc4f5..2163f58 100644 --- a/crates/auctioneer-exchange/tests/sampling_nbr.rs +++ b/crates/auctioneer-exchange/tests/sampling_nbr.rs @@ -85,7 +85,7 @@ mod tests { .register( Box::leak(Box::new(BidderInfo { name: "fake_aaa".into(), - endpoint: "https://demo.example.test/openrtb".into(), + endpoint: Some("https://demo.example.test/openrtb".into()), uses_https: true, ..BidderInfo::default() })), diff --git a/crates/auctioneer-exchange/tests/sparse_request_copies/main.rs b/crates/auctioneer-exchange/tests/sparse_request_copies/main.rs index 2dbfe92..f90fe20 100644 --- a/crates/auctioneer-exchange/tests/sparse_request_copies/main.rs +++ b/crates/auctioneer-exchange/tests/sparse_request_copies/main.rs @@ -131,7 +131,7 @@ mod tests { const NODE_CAP: u32 = 0x0001_0000; static SYNTHETIC_BIDDER: LazyLock = LazyLock::new(|| BidderInfo { name: "fake_aaa".into(), - endpoint: "https://bidder.example.test/openrtb".into(), + endpoint: Some("https://bidder.example.test/openrtb".into()), uses_https: true, ..BidderInfo::default() }); @@ -804,7 +804,7 @@ mod tests { BidderName::new("fake_aaa"), "acct", None, - &SYNTHETIC_BIDDER.endpoint, + SYNTHETIC_BIDDER.endpoint.as_deref().unwrap(), &[], None, None, @@ -899,7 +899,7 @@ mod tests { let scoped = scoped_fake_aaa(&grouped, ®istry, NODE_CAP, usize::try_from(BODY_CAP).unwrap()).unwrap(); let info = BidderRequestInfo::new( BidderName::new("fake_aaa"), BidderName::new("fake_aaa"), "acct", None, - &SYNTHETIC_BIDDER.endpoint, &[], None, None, EntryPoint::Auction, &[], + SYNTHETIC_BIDDER.endpoint.as_deref().unwrap(), &[], None, None, EntryPoint::Auction, &[], ); let budget = CallBudget::new(1); let validator = ExchangeEndpointValidator::new(&SYNTHETIC_BIDDER); diff --git a/crates/auctioneer-exchange/tests/sparse_request_copies/tests/provider_allocation_conformance.rs b/crates/auctioneer-exchange/tests/sparse_request_copies/tests/provider_allocation_conformance.rs index 1230e0b..bd80625 100644 --- a/crates/auctioneer-exchange/tests/sparse_request_copies/tests/provider_allocation_conformance.rs +++ b/crates/auctioneer-exchange/tests/sparse_request_copies/tests/provider_allocation_conformance.rs @@ -201,7 +201,7 @@ fn copied_advisory_headers_on_borrowed_published_transform_fit_the_funded_charge BidderName::new(bidder.name()), "acct", None, - &bidder.info.endpoint, + bidder.info.endpoint.as_deref().unwrap(), &endpoint_macros, host_allowlist.as_deref(), None, @@ -269,7 +269,7 @@ fn published_transform_copies_a_sparse_extension_at_most_once() { let scoped = scoped_provider(&bidder, &grouped, ®istry, NODE_CAP, usize::try_from(BODY_CAP).unwrap()).unwrap(); let info = BidderRequestInfo::new( BidderName::new(bidder.name()), BidderName::new(bidder.name()), "acct", None, - &bidder.info.endpoint, &endpoint_macros, host_allowlist.as_deref(), + bidder.info.endpoint.as_deref().unwrap(), &endpoint_macros, host_allowlist.as_deref(), None, EntryPoint::Auction, &[], ); let budget = CallBudget::new(1); diff --git a/crates/auctioneer-exchange/tests/targeting_prefix/main.rs b/crates/auctioneer-exchange/tests/targeting_prefix/main.rs index fa9c81f..377c9e6 100644 --- a/crates/auctioneer-exchange/tests/targeting_prefix/main.rs +++ b/crates/auctioneer-exchange/tests/targeting_prefix/main.rs @@ -74,7 +74,7 @@ mod tests { fn leak(name: &'static str, endpoint: &'static str) -> &'static BidderInfo { Box::leak(Box::new(BidderInfo { name: name.into(), - endpoint: endpoint.into(), + endpoint: Some(endpoint.into()), uses_https: true, capabilities: Capabilities { site: vec![ diff --git a/crates/auctioneer-exchange/tests/validate_endpoint.rs b/crates/auctioneer-exchange/tests/validate_endpoint.rs index cded9dd..800f77e 100644 --- a/crates/auctioneer-exchange/tests/validate_endpoint.rs +++ b/crates/auctioneer-exchange/tests/validate_endpoint.rs @@ -18,7 +18,7 @@ mod tests { fn info_fixed_host_https() -> BidderInfo { BidderInfo { name: "fake_ax".into(), - endpoint: "https://hb.example.test/openrtb".into(), + endpoint: Some("https://hb.example.test/openrtb".into()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -28,7 +28,7 @@ mod tests { fn info_host_varying() -> BidderInfo { BidderInfo { name: "fake_ax".into(), - endpoint: "https://{{.Host}}/openrtb".into(), + endpoint: Some("https://{{.Host}}/openrtb".into()), uses_https: true, host_allowlist: Some(vec!["eu.example.test".into(), "us.example.test".into()]), ..BidderInfo::default() @@ -73,7 +73,7 @@ mod tests { let info = macro_info(template); let budget = CallBudget::new(1); let configured = BidderInfo { - endpoint: template.into(), + endpoint: Some(template.into()), ..info_fixed_host_https() }; let validator = ExchangeEndpointValidator::new(&configured); @@ -101,7 +101,7 @@ mod tests { let info = macro_info(template); let budget = CallBudget::new(1); let configured = BidderInfo { - endpoint: template.into(), + endpoint: Some(template.into()), ..info_fixed_host_https() }; let validator = ExchangeEndpointValidator::new(&configured); @@ -180,7 +180,7 @@ mod tests { fn private_ip_literals_rejected() { let info = BidderInfo { name: "fake_ax".into(), - endpoint: "https://10.0.0.1/x".into(), + endpoint: Some("https://10.0.0.1/x".into()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -194,7 +194,7 @@ mod tests { fn localhost_literal_rejected_in_default_build() { let info = BidderInfo { name: "fake_ax".into(), - endpoint: "https://localhost/x".into(), + endpoint: Some("https://localhost/x".into()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -208,7 +208,7 @@ mod tests { fn ipv6_loopback_rejected_in_default_build() { let info = BidderInfo { name: "fake_ax".into(), - endpoint: "https://[::1]/x".into(), + endpoint: Some("https://[::1]/x".into()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -229,7 +229,7 @@ mod tests { let url = format!("https://[{literal}]/x"); let info = BidderInfo { name: "fake_ax".into(), - endpoint: url.clone(), + endpoint: Some(url.clone()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -255,7 +255,7 @@ mod tests { let url = format!("https://{literal}/x"); let info = BidderInfo { name: "fake_ax".into(), - endpoint: url.clone(), + endpoint: Some(url.clone()), uses_https: true, host_allowlist: None, ..BidderInfo::default() @@ -271,7 +271,7 @@ mod tests { fn loopback_allowed_under_feature() { let info = BidderInfo { name: "fake_ddddd".into(), - endpoint: "http://127.0.0.1:8787/openrtb2/auction".into(), + endpoint: Some("http://127.0.0.1:8787/openrtb2/auction".into()), uses_https: false, host_allowlist: None, ..BidderInfo::default() diff --git a/docs/architecture.md b/docs/architecture.md index dabfc67..c01d99f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,9 +326,10 @@ before handing calls to the fanout implementation. from `make_bids`; the network operation lives between them. The current registry includes AppNexus, PubMatic, and a generic OpenRTB bidder implementation, alongside controlled-native priority implementations. -Mocktioneer is enabled by the explicit `insecure-bidders` feature or by Axum's -test-only dependency when running its default socket tests. Normal production -builds do not enable it. +Mocktioneer is available in normal builds and disabled by default. Enabling it +requires an explicit operator-configured endpoint. Public HTTPS needs no feature +flag; `insecure-bidders` permits the existing HTTP/exact-loopback development +transport, including Axum's test-only socket fixtures. This is not a claim of compatibility with the entire upstream bidder catalog. [Automatic bidder registration](superpowers/specs/2026-09-27-bidder-registry-design.md) diff --git a/docs/bidders/schema-qualification.md b/docs/bidders/schema-qualification.md index 8600270..3f9542a 100644 --- a/docs/bidders/schema-qualification.md +++ b/docs/bidders/schema-qualification.md @@ -1,5 +1,12 @@ # Schema Compiler Qualification +**2026-11-10 publication amendment:** Mocktioneer is now compiled in both +feature lanes, disabled by default, with an explicit endpoint required to enable +it. Current shipped source checks require 18 native bindings (four guest +bindings), 18 selected roots/members and 16 effective schemas in each lane. +Earlier 15/16-cohort measurements below retain their original historical scope; +the new publication does not requalify those linked artifacts. + **2026-10-30 delivery amendment:** AG approved [controlled-native acceptance](../superpowers/specs/2026-10-30-controlled-native-bidders-completion-design.md) using tested reservations, enforced limits and real handler/allocation/stress diff --git a/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md index 46ad30f..cce897c 100644 --- a/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md +++ b/docs/superpowers/plans/2026-11-09-mocktioneer-runtime-endpoint.md @@ -1,5 +1,7 @@ # Mocktioneer runtime endpoint implementation plan +November 10 publication update: the approved [secure defaults design](../specs/2026-11-10-mocktioneer-secure-default-design.md) supersedes the feature gate and compiled-loopback fallback below. + November 9 format update: the [flat settings plan](2026-11-09-flat-bidder-settings.md) replaces the earlier operator wrapper syntax consistently for every bidder. This plan retains the original endpoint behavior and verification record. diff --git a/docs/superpowers/plans/2026-11-10-mocktioneer-secure-default.md b/docs/superpowers/plans/2026-11-10-mocktioneer-secure-default.md new file mode 100644 index 0000000..ad0f2ab --- /dev/null +++ b/docs/superpowers/plans/2026-11-10-mocktioneer-secure-default.md @@ -0,0 +1,69 @@ +# Mocktioneer secure defaults implementation plan + +> **For agentic workers:** Execute the approved change inline, task by task, +> using test-driven development and verification-before-completion. Obtain an +> independent review before publishing the updated PR. + +**Goal:** Use Mocktioneer with explicit HTTPS configuration in normal builds. + +**Architecture:** Decouple adapter publication from the development transport +exception. Model optional compiled endpoints explicitly, enforce required runtime +settings in the owner, and preserve the existing enforcing sink. + +**Tech stack:** Existing Rust workspace, generated TOML settings, CLI and Fastly. + +**Contract:** [Approved design](../specs/2026-11-10-mocktioneer-secure-default-design.md). + +## Task 1: Prove default publication and required configuration + +- [ ] Add a metadata smoke regression asserting Mocktioneer exists without a + feature, has no compiled endpoint, and is disabled by default. Run + `cargo test --locked --offline -j2 -p auctioneer-config-data --test smoke` + and observe failure against current metadata. +- [ ] Add owner controls for missing endpoints in enabled/disabled identities, + requested/base inheritance and secure/development URL admission. Preserve + independent invalid/shadowed controls. + +## Task 2: Admit and enforce missing compiled endpoints + +- [ ] Change raw/generated `BidderInfo.endpoint` to `Option` in + `auctioneer-config/src/{types.rs,codegen/mod.rs}`. Render existing values + unchanged; derive HTTPS/macros only from present defaults. +- [ ] In `auctioneer-config-data/build.rs`, require a default-disabled factory + with typed endpoint settings for omitted defaults; validate every present URL + and reject unrelated omissions. Add build-harness positive/negative controls. +- [ ] Update metadata consumers and synthetic fixture constructors mechanically. + Make `EndpointBuilder::new` refuse a missing default and keep exchange + selection before credentials/body work. Do not add metadata clones or a new + allocation across the credential await. + +## Task 3: Publish Mocktioneer securely + +- [ ] Remove its `required_feature` and compiled endpoint; set + `defaultEnabled = false` in its manifest. Refresh only its manifest digest in + the protocol fixture index. +- [ ] Validate that enabled identities resolve an endpoint in owner config, + and reject missing endpoints in direct selection. Preserve URL checks on all + supplied values, including disabled/shadowed values. +- [ ] Add a third, configured-endpoint serving cohort to the publication census + and same-App acceptance (default off, explicit disable, missing refusal, HTTPS + dispatch), preserving the frozen 14 priority flows and all resource limits. +- [ ] Exercise the shared missing-default guard with a capable fake, both with + and without a configured endpoint, before credential/body/construction work. +- [ ] Update default/development tests and native qualification configuration to + explicitly activate and configure the selected bidder. Strip those new fields + from historical-binary documents and assert the original row independently. + Keep fixture payloads + and independent error/allocator expectations unchanged. + +## Task 4: Verify and publish + +- [ ] Update README/config examples and supersession notes in the prior design. +- [ ] Run owner/config/core/CLI/endpoint contracts in both feature lanes, then + compiler resource tests and strict affected lint. Use retained Cargo caches + and serialize heavy jobs. Run formatting and fixture/source-isolation gates. +- [ ] Build default and development Fastly guests; use synthetic policy and + isolated Viceroy configuration to verify valid HTTPS admission, missing + endpoint failure and development loopback behavior. Do not deploy. +- [ ] Request independent review, fix findings, and commit only intended files. + Update existing PR #160 and inspect actual CI separately from local checks. diff --git a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md index 55c9414..6e4469f 100644 --- a/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md +++ b/docs/superpowers/specs/2026-11-09-mocktioneer-runtime-endpoint-design.md @@ -1,5 +1,7 @@ # Configure Mocktioneer endpoints through runtime policy +November 10 publication update: the approved [secure defaults design](../specs/2026-11-10-mocktioneer-secure-default-design.md) supersedes the feature gate and compiled-loopback fallback below. + November 9 format update: the maintainer subsequently selected fields directly under `.settings` for every bidder. The [flat settings design](2026-11-09-flat-bidder-settings-design.md) supersedes the wrapper syntax recorded below; endpoint validation and authority diff --git a/docs/superpowers/specs/2026-11-10-mocktioneer-secure-default-design.md b/docs/superpowers/specs/2026-11-10-mocktioneer-secure-default-design.md new file mode 100644 index 0000000..8eba2d6 --- /dev/null +++ b/docs/superpowers/specs/2026-11-10-mocktioneer-secure-default-design.md @@ -0,0 +1,42 @@ +# Make Mocktioneer available with secure defaults + +The maintainer approved this change on November 10 for PR #160. It supersedes +the feature-gated publication and compiled-loopback fallback in the original +[endpoint design](2026-11-09-mocktioneer-runtime-endpoint-design.md). + +Mocktioneer is compiled in normal builds and is disabled by default. Enabling +it requires an operator-configured endpoint; omitted/null settings can inherit +a base identity's endpoint but never an implicit destination. Both normal and +development builds require that explicit configuration. Public HTTPS works in +normal CLI and serving builds. The existing `insecure-bidders` feature permits +only the existing HTTP/exact-loopback development exception for configured +Mocktioneer endpoints. URL admission, request-level override rejection, safe +diagnostics and captured sink authority remain unchanged. + +Compiled bidder metadata represents absence of an endpoint with `Option` +instead of an invalid URL or a placeholder destination. Existing endpoint strings +retain their exact values. Build admission allows an absent endpoint only for a +factory with typed endpoint settings whose definition is disabled by default; +empty or otherwise invalid supplied defaults still fail. This prevents accidental +endpoint omission from silently changing other bidders' contracts. The endpoint +builder rejects missing templates, and the exchange cannot construct or send a +request when neither configuration nor metadata provides a destination. + +Mocktioneer's validator checks every supplied endpoint even when disabled or +shadowed. It additionally rejects an enabled identity without a resolved endpoint, +using the static reason `required when enabled`. Direct endpoint selection also +rejects absence, independently of prior configuration validation. Alias settings +inherit through the existing requested/base resolver; activation and the +`implementation` selector keep their current independent roles. + +Add explicit generated-App acceptance for the configured-endpoint publication +cohort while preserving the frozen 14 priority flows. Historical qualification +translation retains its original timeout/sample-rate bidder row independently. + +Update owner-local tests, metadata/build admission controls, shared synthetic +fixtures and CLI qualification setup to select destinations explicitly. Keep +protocol captures, frozen allocation thresholds and workload limits unchanged. +Run both default and development lanes, public settings/endpoint contracts, +compiler/allocation probes, strict lint, provider compilation and actual local +Fastly guest checks. Preserve local configuration and do not contact a hosted +bidder or deploy as part of verification. diff --git a/docs/testing/resource-qualification.md b/docs/testing/resource-qualification.md index 2b81f29..efaa203 100644 --- a/docs/testing/resource-qualification.md +++ b/docs/testing/resource-qualification.md @@ -147,8 +147,8 @@ processes. No raw environment is hashed. OS/meter versions identify the kernel interface; native runtime version identifies the Axum package. New identities also bind the Rust harness source inventory, executable and fixture protocol 1. -The bidder is a separate `auctioneer-cli qualify fixture` Rust process on the compiled -Mocktioneer endpoint `http://127.0.0.1:8787/openrtb2/auction`. Auctioneer uses a different port. +The bidder is a separate `auctioneer-cli qualify fixture` Rust process on the explicitly +configured Mocktioneer endpoint `http://127.0.0.1:8787/openrtb2/auction`. Auctioneer uses a different port. The private child reads bounded newline-delimited JSON on stdin, starting with closed `Options` tagged `kind: "native"`, with `port`, `body_cap`, `max_requests` and `max_sockets`, and announces diff --git a/docs/testing/usersync-browser.md b/docs/testing/usersync-browser.md index f90062b..31e68f9 100644 --- a/docs/testing/usersync-browser.md +++ b/docs/testing/usersync-browser.md @@ -53,7 +53,8 @@ Axum's socket-test dev-dependency enables `insecure-bidders`, including in an `--all-targets` test invocation. That test binary is not a secure-default production build. Run `./scripts/check-secure-usersync.sh` to build the production dependency graph separately and verify real socket rejection of -loopback callback configuration and absence of the test bidder. CI runs this +loopback callback configuration and default-disabled availability of the +configurable bidder. CI runs this check explicitly; it retains the production binary before compiling tests. The check verifies browser-generated JSON preflight, credentialed CORS, the From de559d0c2a0d921ece73791cc1f370429d1f1fff Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:13:50 -0800 Subject: [PATCH 09/14] Fix Mocktioneer publication checks --- .../src/bidders/mocktioneer/bidder.toml | 3 ++- .../bidders/mocktioneer/fixtures/factory.json | 2 +- .../tests/schema_selected_semantics/mod.rs | 22 +++++++------------ .../tests/bidder_bindings.rs | 12 +++++++--- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml index 816e2bd..db034a5 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/bidder.toml @@ -56,7 +56,8 @@ source_blob = "693721b93363c430d97fa8d4bf7649dfdd56ddaa" source_bytes = 497 source_sha256 = "86aef303c594f1a249f44cb0681a9cceec9c7887ef29ce4a228616f699e398c8" transformation = """\ - Effective capabilities retained with an operator-required destination; no verbatim metadata capture is claimed.""" + Effective capabilities retained with an operator-required destination; \ + no verbatim metadata capture is claimed.""" port_differences = [ """\ The operator-required endpoint and Auctioneer maintainer address differ \ diff --git a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json index c467e9f..57f6c62 100644 --- a/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json +++ b/crates/auctioneer-bidder/src/bidders/mocktioneer/fixtures/factory.json @@ -1,7 +1,7 @@ { "schema_version": 1, "implementation": "mocktioneer", - "manifest_sha256": "03a6a92f76bacd5cce2ebd91e5221412127e2f1f1c038ee38bec53139ac2e0a1", + "manifest_sha256": "8b47c3ce9febdfd1c66f2a7bd548bdf3dda10063e97bcca98866a0427d8c7ddf", "cases": [ { "name": "banner-and-unsent-sibling", diff --git a/crates/auctioneer-bidder/tests/schema_selected_semantics/mod.rs b/crates/auctioneer-bidder/tests/schema_selected_semantics/mod.rs index fedb1e5..0525e71 100644 --- a/crates/auctioneer-bidder/tests/schema_selected_semantics/mod.rs +++ b/crates/auctioneer-bidder/tests/schema_selected_semantics/mod.rs @@ -526,7 +526,9 @@ mod tests { #[test] fn selected_semantics_both_lanes_have_exact_selected_owners_and_fixed_import_count() { - for (lane, count) in [(Lane::Secure, 15), (Lane::Development, 16)] { + // Mocktioneer's eight authored cases now belong to both build lanes. + for lane in [Lane::Secure, Lane::Development] { + let count = 16; let cohort = selected_cohort(lane).unwrap(); let batch = selected(lane).unwrap(); let wanted: BTreeSet<_> = cohort @@ -540,19 +542,11 @@ mod tests { assert_eq!(batch.schema_sha256.len(), count); assert_eq!(batch.data_sha256.len(), count); assert_eq!(batch.imported_sha256.len(), 1); - let golden = match lane { - Lane::Secure => Counts { - authored: 139, - imported: 14, - accepts: 57, - rejects: 96, - }, - Lane::Development => Counts { - authored: 147, - imported: 14, - accepts: 61, - rejects: 100, - }, + let golden = Counts { + authored: 147, + imported: 14, + accepts: 61, + rejects: 100, }; assert_eq!(batch.counts, golden); assert_eq!( diff --git a/crates/auctioneer-config-data/tests/bidder_bindings.rs b/crates/auctioneer-config-data/tests/bidder_bindings.rs index 718e5ff..4657394 100644 --- a/crates/auctioneer-config-data/tests/bidder_bindings.rs +++ b/crates/auctioneer-config-data/tests/bidder_bindings.rs @@ -44,7 +44,7 @@ mod tests { } #[test] - fn generated_all_declarations_preserve_secure_and_feature_gated_rows() { + fn generated_all_declarations_preserve_unconditional_and_target_gated_rows() { // Read source declarations independently of the catalog/compiler under test. let root = Path::new(env!("CARGO_MANIFEST_DIR")) .with_file_name("auctioneer-bidder") @@ -84,11 +84,17 @@ mod tests { } } assert!(!expected.is_empty()); - assert!(expected.values().any(|(_, feature, _)| feature.is_none())); + // Product publications cover both target gates. Optional Cargo feature + // gates have independent synthetic discovery/compiler controls. assert!( expected .values() - .any(|(_, feature, _)| feature.as_deref() == Some("insecure-bidders")) + .any(|(_, feature, native_only)| feature.is_none() && *native_only) + ); + assert!( + expected + .values() + .any(|(_, feature, native_only)| feature.is_none() && !*native_only) ); let declarations = bidder_metadata_declarations(); let actual: Vec<_> = declarations From 9f4931d276c825e767a8e155cbfdc2075cbc456f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:50:52 -0800 Subject: [PATCH 10/14] Honor the default runtime config store --- crates/auctioneer-core/src/config/runtime.rs | 19 +-- .../tests/resolve_runtime_config.rs | 140 +++++++++++++++++- 2 files changed, 147 insertions(+), 12 deletions(-) diff --git a/crates/auctioneer-core/src/config/runtime.rs b/crates/auctioneer-core/src/config/runtime.rs index adc3a9d..adf473a 100644 --- a/crates/auctioneer-core/src/config/runtime.rs +++ b/crates/auctioneer-core/src/config/runtime.rs @@ -28,13 +28,10 @@ use edgezero_core::body::Body; use edgezero_core::config_store::ConfigExtractionLimits; use edgezero_core::context::RequestContext; use edgezero_core::error::{EdgeError, StoreExtractionReason}; -use edgezero_core::extractor::AppConfig; +use edgezero_core::extractor::{AppConfig, FromRequest as _}; use edgezero_core::http::{Response, StatusCode, response_builder}; use std::time::Duration; -/// The logical config-store id the deploy binds `auctioneer.toml` to. -const CONFIG_STORE_ID: &str = "auctioneer_runtime"; - /// Fixed bound for the SDK's root-config and secret extraction, independent of operator policy. pub const CONFIG_FETCH_TIMEOUT: Duration = Duration::from_secs(2); @@ -319,8 +316,8 @@ impl RuntimeConfigError { /// store reads require the context's complete captured extraction tuple to fit /// the funded host declaration. Backend/secret outages and deadline expiry stay /// transient (503 with `Retry-After`); invalid keys retain their distinct refusal -/// code and permanent 500 disposition. The bound key comes from the selected -/// store binding, not [`CONFIG_STORE_ID`], which only selects the binding. +/// code and permanent 500 disposition. The request context's declared default +/// config-store binding selects both the store and its entry key. /// /// `/status` alone may explicitly take [`RuntimeConfigError::degrade_to_default`] /// after recording the closed fault code, because it makes no privacy decision. @@ -366,14 +363,14 @@ async fn resolve_with_registry( if let Some(error) = cached_registry_error() { return Err(error.into()); } - if ctx.config_store_binding(CONFIG_STORE_ID).is_none() { + if ctx.config_store_default_binding().is_none() { return Err(missing_config("no config store binding", None)); } check_funding()?; initialize_registry()?; - AppConfig::::from_store(ctx, CONFIG_STORE_ID, None) + AppConfig::::from_request(ctx) .await - .map(|config| config.0) + .map(|AppConfig(config)| config.0) .map_err(classify_load_error) } @@ -738,9 +735,9 @@ mod tests { .body(Body::empty()) .unwrap(); let stores: ConfigRegistry = StoreRegistry::single_id( - super::CONFIG_STORE_ID.to_owned(), + "fake_default_config".to_owned(), ConfigStoreBinding { - default_key: super::CONFIG_STORE_ID.to_owned(), + default_key: "fake_default_entry".to_owned(), handle: ConfigStoreHandle::new(Arc::new(NoReads)), }, ); diff --git a/crates/auctioneer-core/tests/resolve_runtime_config.rs b/crates/auctioneer-core/tests/resolve_runtime_config.rs index 6d3edb5..0bf068e 100644 --- a/crates/auctioneer-core/tests/resolve_runtime_config.rs +++ b/crates/auctioneer-core/tests/resolve_runtime_config.rs @@ -55,6 +55,7 @@ mod tests { use futures::executor::block_on; use http::{Method, Request, Uri}; use std::mem; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use time::OffsetDateTime; @@ -195,6 +196,13 @@ mod tests { } fn context_with(store: Arc, fanout: Arc) -> RequestContext { + context_with_registry(registry_of(store), fanout) + } + + fn context_with_registry( + registry: ConfigRegistry, + fanout: Arc, + ) -> RequestContext { config_funding::install(); let handler_fanout: Arc = fanout; let clock: Arc = @@ -206,7 +214,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from_bytes(fixture_body())) .expect("request builds"); - request.extensions_mut().insert(registry_of(store)); + request.extensions_mut().insert(registry); request .extensions_mut() .insert(TestFanoutHandle(handler_fanout)); @@ -303,6 +311,136 @@ mod tests { ); } + struct CheckedStore { + inner: Arc, + expected_key: &'static str, + bounded_reads: AtomicUsize, + } + + impl CheckedStore { + fn new(inner: Arc, expected_key: &'static str) -> Arc { + Arc::new(Self { + inner, + expected_key, + bounded_reads: AtomicUsize::new(0), + }) + } + + fn binding(self: &Arc, key: &str) -> ConfigStoreBinding { + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::::clone(self)), + default_key: key.to_owned(), + } + } + } + + #[async_trait(?Send)] + impl ConfigStore for CheckedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + panic!("runtime configuration must use bounded extraction") + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + assert_eq!(key, self.expected_key); + self.bounded_reads.fetch_add(1, Ordering::SeqCst); + self.inner + .get_bounded(key, clock, deadline, max_backend_bytes, max_value_bytes) + .await + } + } + + #[test] + fn default_store_can_have_a_different_id_and_entry_key() { + let store = CheckedStore::new(Arc::new(KnownAliasBaseStore), "published_policy"); + let registry = StoreRegistry::single_id( + "publisher_runtime".to_owned(), + store.binding("published_policy"), + ); + let ctx = context_with_registry(registry, Arc::new(RecordingFanout::default())); + assert!(ctx.config_store_binding("auctioneer_runtime").is_none()); + assert_eq!(store.bounded_reads.load(Ordering::SeqCst), 0); + + let config = block_on(resolve_runtime_config(&ctx)) + .expect("the declared default must load without the historical store id"); + + assert_eq!( + config.host.aliases.as_ref().unwrap()["fake_generic_alias"], + passthrough_info().name, + ); + assert_eq!(store.bounded_reads.load(Ordering::SeqCst), 1); + } + + #[test] + fn default_store_is_selected_when_the_historical_binding_is_also_present() { + let selected = CheckedStore::new(Arc::new(KnownAliasBaseStore), "selected_policy"); + let historical = CheckedStore::new(Arc::new(UnknownAliasBaseStore), "other_policy"); + let registry = StoreRegistry::from_parts( + [ + ( + "auctioneer_runtime".to_owned(), + historical.binding("other_policy"), + ), + ( + "zz_publisher_runtime".to_owned(), + selected.binding("selected_policy"), + ), + ] + .into(), + "zz_publisher_runtime".to_owned(), + ) + .expect("the selected default is declared"); + let ctx = context_with_registry(registry, Arc::new(RecordingFanout::default())); + assert!(ctx.config_store_binding("auctioneer_runtime").is_some()); + assert_eq!(selected.bounded_reads.load(Ordering::SeqCst), 0); + assert_eq!(historical.bounded_reads.load(Ordering::SeqCst), 0); + + block_on(resolve_runtime_config(&ctx)).expect("only the declared default is selected"); + + assert_eq!(selected.bounded_reads.load(Ordering::SeqCst), 1); + assert_eq!(historical.bounded_reads.load(Ordering::SeqCst), 0); + } + + #[test] + fn default_store_validation_failure_never_falls_back_to_the_historical_binding() { + let selected = CheckedStore::new(Arc::new(UnknownAliasBaseStore), "selected_policy"); + let historical = CheckedStore::new(Arc::new(KnownAliasBaseStore), "other_policy"); + let registry = StoreRegistry::from_parts( + [ + ( + "auctioneer_runtime".to_owned(), + historical.binding("other_policy"), + ), + ( + "zz_publisher_runtime".to_owned(), + selected.binding("selected_policy"), + ), + ] + .into(), + "zz_publisher_runtime".to_owned(), + ) + .expect("the selected default is declared"); + let ctx = context_with_registry(registry, Arc::new(RecordingFanout::default())); + assert!(ctx.config_store_binding("auctioneer_runtime").is_some()); + + let error = block_on(resolve_runtime_config(&ctx)) + .expect_err("a malformed default must not use another valid publication"); + + assert!(matches!(error, RuntimeConfigError::MalformedHost { .. })); + assert_eq!( + error.into_response().unwrap().status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!(selected.bounded_reads.load(Ordering::SeqCst), 1); + assert_eq!(historical.bounded_reads.load(Ordering::SeqCst), 0); + } + mod policy_envelope { use super::{STORE_ID, registry_of}; use async_trait::async_trait; From 6770d270ddf196511d0d1c006e3fa5b59b2b7787 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:34:45 -0800 Subject: [PATCH 11/14] Align default store bindings across adapters --- .../tests/manifest_usersync_cookie_keys.rs | 8 ++-- .../wrangler.toml | 6 +-- crates/auctioneer-adapter-fastly/src/lib.rs | 6 +-- .../tests/manifest_health_stores.rs | 7 ++-- .../tests/manifest_usersync_cookie_keys.rs | 4 +- crates/auctioneer-cli/src/application.rs | 37 +++++++++++++++++-- docs/deployment/usersync-cookie-keys.md | 10 +++-- edgezero.toml | 8 ++-- fastly.toml | 13 +++---- 9 files changed, 64 insertions(+), 35 deletions(-) diff --git a/crates/auctioneer-adapter-cloudflare/tests/manifest_usersync_cookie_keys.rs b/crates/auctioneer-adapter-cloudflare/tests/manifest_usersync_cookie_keys.rs index 3106799..1aceaa2 100644 --- a/crates/auctioneer-adapter-cloudflare/tests/manifest_usersync_cookie_keys.rs +++ b/crates/auctioneer-adapter-cloudflare/tests/manifest_usersync_cookie_keys.rs @@ -13,12 +13,12 @@ mod tests { let stores = ::stores() .secrets .expect("the default secret store exists"); - assert_eq!(stores.default, "default"); - assert_eq!(stores.ids, &["default"]); + assert_eq!(stores.default, "auctioneer_secrets"); + assert_eq!(stores.ids, &["auctioneer_secrets"]); let variables = manifest["vars"].as_table().expect("Worker variables"); assert_eq!( - variables["EDGEZERO__STORES__SECRETS__DEFAULT__NAME"].as_str(), - Some("default") + variables["EDGEZERO__STORES__SECRETS__AUCTIONEER_SECRETS__NAME"].as_str(), + Some("auctioneer_secrets") ); for name in [ "usersync_cookie_keyring", diff --git a/crates/auctioneer-adapter-cloudflare/wrangler.toml b/crates/auctioneer-adapter-cloudflare/wrangler.toml index 1d5fef0..1ec599c 100644 --- a/crates/auctioneer-adapter-cloudflare/wrangler.toml +++ b/crates/auctioneer-adapter-cloudflare/wrangler.toml @@ -8,9 +8,9 @@ compatibility_flags = ["enable_weak_ref"] [vars] EDGEZERO__LOGGING__LEVEL = "INFO" AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH = "false" -EDGEZERO__STORES__CONFIG__AUCTIONEER_RUNTIME__NAME = "auctioneer_runtime" -EDGEZERO__STORES__CONFIG__AUCTIONEER_RUNTIME__KEY = "auctioneer_runtime" -EDGEZERO__STORES__SECRETS__DEFAULT__NAME = "default" +EDGEZERO__STORES__CONFIG__AUCTIONEER_CONFIG__NAME = "auctioneer_config" +EDGEZERO__STORES__CONFIG__AUCTIONEER_CONFIG__KEY = "auctioneer_config" +EDGEZERO__STORES__SECRETS__AUCTIONEER_SECRETS__NAME = "auctioneer_secrets" # The generic reader queries these; Workers owns the listener, so neither # configures a socket on this target. EDGEZERO__ADAPTER__HOST = "" diff --git a/crates/auctioneer-adapter-fastly/src/lib.rs b/crates/auctioneer-adapter-fastly/src/lib.rs index 8aad879..203a0fc 100644 --- a/crates/auctioneer-adapter-fastly/src/lib.rs +++ b/crates/auctioneer-adapter-fastly/src/lib.rs @@ -206,10 +206,8 @@ fn resolve_health_policy() -> Result { // The store NAMES come from the manifest's `[stores.*]` // declarations, which `app!` bakes into `Hooks::stores()` — not from // a literal here. A literal is how boot and provisioning came to - // disagree: `edgezero.toml` declares config store `auctioneer_runtime` - // and secret store `default`, so a hardcoded "auctioneer" opened stores - // nothing creates, and health installation failed before routing - // could answer even `/status`. + // disagree: opening an unrelated hardcoded alias failed health installation + // before routing could answer even `/status`. // // Fail closed when a declaration is absent rather than falling back // to a guess: a missing `[stores.*]` entry means the deployment was diff --git a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs index 20b98b9..80fad0e 100644 --- a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs +++ b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs @@ -191,9 +191,10 @@ exit 71 .and_then(Value::as_str); assert_eq!(flag, Some("false")); assert!(!read_bool_flag("allow_unauthenticated_health", flag).unwrap()); - assert!( - contents.get(config_id).and_then(Value::as_str).is_some(), - "health provisioning must preserve the existing runtime config envelope" + assert_eq!( + contents.get(config_id).and_then(Value::as_str), + Some(""), + "local runtime policy must be supplied by config push rather than the shipped manifest" ); assert!( !contents.contains_key("health_gvl_token"), diff --git a/crates/auctioneer-adapter-fastly/tests/manifest_usersync_cookie_keys.rs b/crates/auctioneer-adapter-fastly/tests/manifest_usersync_cookie_keys.rs index 3667040..c19c812 100644 --- a/crates/auctioneer-adapter-fastly/tests/manifest_usersync_cookie_keys.rs +++ b/crates/auctioneer-adapter-fastly/tests/manifest_usersync_cookie_keys.rs @@ -13,8 +13,8 @@ mod tests { let stores = ::stores() .secrets .expect("the default secret store exists"); - assert_eq!(stores.default, "default"); - assert_eq!(stores.ids, &["default"]); + assert_eq!(stores.default, "auctioneer_secrets"); + assert_eq!(stores.ids, &["auctioneer_secrets"]); let local_stores = manifest["local_server"]["secret_stores"] .as_table() .expect("local secret stores"); diff --git a/crates/auctioneer-cli/src/application.rs b/crates/auctioneer-cli/src/application.rs index 516d090..9ff34b6 100644 --- a/crates/auctioneer-cli/src/application.rs +++ b/crates/auctioneer-cli/src/application.rs @@ -91,6 +91,7 @@ pub(crate) fn run(command: Command) -> i32 { mod config_contract { use super::ConfigValidateArgs; use auctioneer_config::build_catalog; + use auctioneer_core::App; use auctioneer_core::config::resolve::{ AccountSelection, resolve_host, resolve_selected_account, resolved_account, }; @@ -99,6 +100,7 @@ mod config_contract { validate_effective_defaults, }; use clap::Parser as _; + use edgezero_core::app::Hooks; use edgezero_core::app_config::{ AppConfigLoadOptions, AppConfigMeta as _, load_app_config_with_options, }; @@ -737,11 +739,32 @@ mod config_contract { } #[test] - fn fastly_local_envelope_uses_the_same_runtime_schema_and_actual_integrity_helper() { + fn fastly_local_config_starts_empty_until_push() { let manifest: toml::Value = toml::from_str(include_str!("../../../fastly.toml")).unwrap(); - let wire = manifest["local_server"]["config_stores"]["auctioneer_runtime"]["contents"] - ["auctioneer_runtime"].as_str().unwrap(); - let envelope: BlobEnvelope = serde_json::from_str(wire).unwrap(); + let config_id = ::stores() + .config + .expect("the app declares a config store") + .default; + let wire = manifest["local_server"]["config_stores"][config_id]["contents"][config_id] + .as_str() + .unwrap(); + assert_eq!(wire, ""); + } + + #[test] + fn runtime_envelope_fixture_uses_the_same_schema_and_actual_integrity_helper() { + // Retain the independently hashed neutral fixture without shipping a + // policy in the local manifest. The recorded digest is not recomputed. + let mut envelope: BlobEnvelope = serde_json::from_value(json!({ + "data": { + "account_defaults": {}, "accounts": {}, "bidders": {}, + "features": {}, "host": {"resource_limits": {}}, "schema_version": 1_u64, + }, + "generated_at": "2026-10-07T04:35:15Z", + "sha256": "6aa4c01132d4dd3425c13e28cab5c2fb1fec177d6303f1b3e3a863b08adc7778", + "version": 1_u32, + })) + .unwrap(); envelope.verify().unwrap(); assert_eq!(envelope.data["schema_version"], 1_u32); assert!(envelope.data.get("runtime").is_none()); @@ -749,6 +772,12 @@ mod config_contract { let source: SdkAuctioneerAppConfig = serde_json::from_value(envelope.data.clone()).unwrap(); source.validate().unwrap(); assert_eq!(serde_json::to_value(source).unwrap(), envelope.data); + assert!(envelope.data["host"].get("status_response").is_none()); + envelope.data["host"]["status_response"] = json!("fake_changed_banner"); + assert!( + envelope.verify().is_err(), + "a changed payload must fail integrity" + ); } // The SDK reads the process environment; children preserve libtest isolation diff --git a/docs/deployment/usersync-cookie-keys.md b/docs/deployment/usersync-cookie-keys.md index 072a926..1f52e85 100644 --- a/docs/deployment/usersync-cookie-keys.md +++ b/docs/deployment/usersync-cookie-keys.md @@ -1,7 +1,8 @@ # User-Sync Cookie Keys Provision the secret named `usersync_cookie_keyring` through the existing -default logical EdgeZero secret-store binding (`default`). Do not add a store +default logical EdgeZero secret-store binding (`auctioneer_secrets` in the shipped +manifest). Do not add a store ID, put keys in runtime/account config, or bake them into manifests or builds. The secret is one complete JSON value; do not publish `active` and individual keys as separate secrets. @@ -45,7 +46,7 @@ provider-specific provisioning surfaces: | Axum `EnvSecretStore` | Process variable `EDGEZERO__SECRETS__USERSYNC_COOKIE_KEYRING` | | Cloudflare | Worker secret binding `usersync_cookie_keyring` | | Spin | Application variable `usersync_cookie_keyring`, mapped into the component | -| Fastly production | Secret Store item `usersync_cookie_keyring`, resource-link alias `default` | +| Fastly production | Secret Store item `usersync_cookie_keyring`, resource-link alias `auctioneer_secrets` | | Fastly local Viceroy | Same store/item; the manifest's example env source is `AUCTIONEER__USERSYNC__COOKIE_KEYRING` | ### Axum @@ -110,8 +111,9 @@ validates manifest loading, not guest execution or secret-provider delivery. ### Fastly -In production, link the existing Secret Store under resource-link alias -`default`. Add or replace the item `usersync_cookie_keyring` with the entire +In production, link the existing Secret Store under the alias selected by +`[stores.secrets].default` (`auctioneer_secrets` in the shipped manifest). +Add or replace the item `usersync_cookie_keyring` with the entire JSON value using secure upload. Keep `health_gvl_token` intact in that store. The pinned [FastlySecretStore](https://github.com/IABTechLab/edgezero/blob/f6c708ccb5be19ba9fa92575db3c08c0f17e0d72/crates/edgezero-adapter-fastly/src/secret_store.rs) diff --git a/edgezero.toml b/edgezero.toml index 5d52e9a..bfc2c1e 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -139,12 +139,12 @@ description = "Read current UID cookie families" # aliases. Other adapters resolve their bindings through their host setup. [stores.config] -ids = ["auctioneer_runtime"] -default = "auctioneer_runtime" +ids = ["auctioneer_config"] +default = "auctioneer_config" [stores.secrets] -ids = ["default"] -default = "default" +ids = ["auctioneer_secrets"] +default = "auctioneer_secrets" # -- Adapters and their build/deploy/serve commands ------------------------ # diff --git a/fastly.toml b/fastly.toml index 4ca2868..4f4b4a2 100644 --- a/fastly.toml +++ b/fastly.toml @@ -9,24 +9,23 @@ service_id = "" [local_server.config_stores] -[local_server.config_stores.auctioneer_runtime] +[local_server.config_stores.auctioneer_config] format = "inline-toml" -[local_server.config_stores.auctioneer_runtime.contents] -auctioneer_runtime = '{"data":{"account_defaults":{},"accounts":{},"bidders":{},"features":{},"host":{"resource_limits":{}},"schema_version":1},"generated_at":"2026-10-07T04:35:15Z","sha256":"6aa4c01132d4dd3425c13e28cab5c2fb1fec177d6303f1b3e3a863b08adc7778","version":1}' +[local_server.config_stores.auctioneer_config.contents] +# Populate locally with: auctioneer-cli config push --adapter fastly --local. +auctioneer_config = '' # Health remains closed unless a local development token is supplied. allow_unauthenticated_health = "false" -[local_server.secret_stores] - -[[local_server.secret_stores.default]] +[[local_server.secret_stores.auctioneer_secrets]] key = "health_gvl_token" # Viceroy uses an empty value when this host environment variable is unset. # Supply a development-only token when running `fastly compute serve`. env = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN" -[[local_server.secret_stores.default]] +[[local_server.secret_stores.auctioneer_secrets]] key = "usersync_cookie_keyring" # Local Viceroy provisioning only: this example host env name is not a # runtime config knob or a production secret lookup name. Unset means empty. From 14f024d434f5ca91b459ed95d6c6627b63fd8ce8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:47:04 -0800 Subject: [PATCH 12/14] Configure GVL health authentication through runtime policy --- Cargo.lock | 1 + README.md | 33 +- auctioneer.toml | 9 + .../src/baseline_probe.rs | 13 +- .../auctioneer-adapter-axum/src/envelope.rs | 23 +- crates/auctioneer-adapter-axum/src/main.rs | 30 +- .../auctioneer-adapter-axum/tests/README.md | 6 + .../tests/health_boot.rs | 92 +-- .../tests/resource_profile.rs | 116 +++- .../src/health.rs | 381 ++++--------- .../auctioneer-adapter-cloudflare/src/lib.rs | 31 +- .../tests/manifest_runtime_variables.rs | 25 +- .../wrangler.toml | 7 +- crates/auctioneer-adapter-fastly/Cargo.toml | 1 + crates/auctioneer-adapter-fastly/src/lib.rs | 288 +++------- .../tests/boot_logging.rs | 11 +- .../tests/boot_logging_and_stores.rs | 35 +- .../tests/manifest_health_stores.rs | 532 +++++------------- crates/auctioneer-adapter-spin/spin.toml | 20 +- crates/auctioneer-adapter-spin/src/health.rs | 381 ++++--------- crates/auctioneer-adapter-spin/src/lib.rs | 22 +- .../tests/manifest_health_variables.rs | 136 +---- crates/auctioneer-cli/src/application.rs | 2 + crates/auctioneer-config/src/policy.rs | 179 +++++- crates/auctioneer-config/tests/root_shape.rs | 14 +- crates/auctioneer-core/src/auction/audit.rs | 9 +- crates/auctioneer-core/src/config/runtime.rs | 32 +- .../src/handlers/health_gvl.rs | 297 +++++++++- .../src/host_primitives/mod.rs | 9 +- .../src/host_primitives/registry.rs | 252 +-------- crates/auctioneer-core/tests/health_gvl.rs | 33 +- .../auctioneer-core/tests/health_gvl_auth.rs | 210 +++++-- .../auctioneer-core/tests/health_gvl_docs.rs | 3 +- .../tests/health_gvl_failclosed.rs | 12 +- .../tests/serialize_allocation_bound.rs | 22 +- docs/architecture.md | 15 +- .../restrict-operational-endpoints.md | 40 +- .../plans/2026-11-10-gvl-health-policy.md | 30 + ...perator-runtime-configuration-inventory.md | 6 +- .../2026-11-10-gvl-health-policy-design.md | 61 ++ edgezero.toml | 4 +- fastly.toml | 4 +- 42 files changed, 1584 insertions(+), 1843 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-10-gvl-health-policy.md create mode 100644 docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md diff --git a/Cargo.lock b/Cargo.lock index 6109fcd..0cdef8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "futures", "log", "once_cell", + "serde_json", "time", "toml 0.8.23", ] diff --git a/README.md b/README.md index 02c9949..0562859 100644 --- a/README.md +++ b/README.md @@ -284,11 +284,34 @@ and host-allowlist rules remain unchanged. `GET /_auctioneer/health/gvl` reports GVL archive health (archive age, bundled vendor-list versions, and the rolling unavailable-lookup -counter). The handler is fail-closed: it answers 401 unless the adapter -boot installed an explicit access policy — either a bearer token -(`AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` on Axum / Cloudflare; the platform -secret store on Fastly / Spin) or the machine-checkable -`allow_unauthenticated_health = true` opt-in. +counter). Configure access in the pushed operator policy: + +```toml +[gvl.authenticated_health] +enabled = true +token_secret = "health_gvl_token" +``` + +The handler is fail-closed. `token_secret` names an entry in the declared +default Secret Store. Omit the section to keep health closed. To permit anonymous access, set `enabled = false` +and omit `token_secret`. The shared handler resolves config and the selected +secret on each health request; updates take effect with the provider's existing +config/secret visibility lifecycle. Axum's file-backed config keeps its startup +snapshot until restart. Missing or invalid policy, unavailable/empty secrets, +and secrets larger than 4096 bytes return 401 with a Bearer challenge. `/status` +remains independent. Ordinary routes do not fetch health credentials. + +Provision `health_gvl_token` through the existing SDK secret binding: + +- Axum: `EDGEZERO__SECRETS__HEALTH_GVL_TOKEN`; entry names accept ASCII letters, + digits and underscores and are uppercased for this environment lookup. +- Cloudflare: `wrangler secret put health_gvl_token` uses the entry name literally. +- Fastly: the `health_gvl_token` item in the linked default secret-store alias. + The local manifest's `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` is only a Viceroy + provisioning source; the runtime does not read that environment variable. +- Spin: a secret application variable declared and mapped into the component. + The SDK lowercases entry names; a different `token_secret` needs its own + matching declaration and component mapping. Deployments that opt in to serving `/_auctioneer/*` without a token MUST restrict the `/_auctioneer/*` path prefix at the ingress layer. That diff --git a/auctioneer.toml b/auctioneer.toml index 0ea0177..2cf55d8 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -28,6 +28,15 @@ schema_version = 1 +# Omit this section to keep GVL health closed. The value is a secret entry name +# in the declared default Secret Store, never token bytes. Names are 1..128 UTF-8 +# bytes, without ASCII controls or surrounding whitespace. enabled is required. +# Anonymous access requires enabled = false and omission of token_secret; restrict +# /_auctioneer/* at ingress when choosing that mode. +[gvl.authenticated_health] +enabled = true +token_secret = "health_gvl_token" + [host] # Plain-text response used by the status handler. status_response = "auctioneer local" diff --git a/crates/auctioneer-adapter-axum/src/baseline_probe.rs b/crates/auctioneer-adapter-axum/src/baseline_probe.rs index 72be908..e0059d2 100644 --- a/crates/auctioneer-adapter-axum/src/baseline_probe.rs +++ b/crates/auctioneer-adapter-axum/src/baseline_probe.rs @@ -31,7 +31,6 @@ //! It also removes the reading's dependence on page-cache state, which //! is what makes repeated runs comparable rather than a lottery. -use std::env; use std::hint::black_box; use std::io::{self, Write as _}; use std::sync::Arc; @@ -40,15 +39,12 @@ use std::time::Duration; use auctioneer_adapter_axum::{envelope, timer::TokioTimer}; use auctioneer_core::host_primitives::{ - BearerToken, DevRng, SystemClock, axum_production_strategy, bootstrap_adapter_with_envelope, - decide_health_policy, install_health_primitives, read_bool_flag, + DevRng, SystemClock, axum_production_strategy, bootstrap_adapter_with_envelope, + install_health_counter_scope, }; use auctioneer_runtime::NoopDecodeCooperator; use edgezero_core::env_config::EnvConfig; -const HEALTH_TOKEN_VAR: &str = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN"; -const ALLOW_UNAUTH_VAR: &str = "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH"; - /// This host's page size. The touch stride only has to be no larger /// than the real page size to reach every page; 4 KiB is the /// measurement contract's stated stride and is a divisor of this host's @@ -133,10 +129,7 @@ fn main() -> anyhow::Result<()> { ) .map_err(|error| anyhow::anyhow!("boot envelope rejected: {error}"))?; - let raw_flag = env::var(ALLOW_UNAUTH_VAR).ok(); - let allow_unauth = read_bool_flag(ALLOW_UNAUTH_VAR, raw_flag.as_deref())?; - let token = env::var(HEALTH_TOKEN_VAR).ok().and_then(BearerToken::new); - install_health_primitives(decide_health_policy(token, allow_unauth)?, "process")?; + install_health_counter_scope("process")?; let walked = touch_embedded_archive(); diff --git a/crates/auctioneer-adapter-axum/src/envelope.rs b/crates/auctioneer-adapter-axum/src/envelope.rs index 18932ad..ef78751 100644 --- a/crates/auctioneer-adapter-axum/src/envelope.rs +++ b/crates/auctioneer-adapter-axum/src/envelope.rs @@ -733,8 +733,9 @@ mod tests { .unwrap(); assert_eq!( derived.total_bytes, - // Two admitted auctions each retain the measured bridge owners. - 2_536_754_680 + 2 * 128 * 1024 + 26_214_400 + 2 * 19_160 + // Two admitted auctions each retain the prior 19,160-byte bridge + // owners plus 96 measured bytes for the GVL policy execution frame. + 2_536_754_680 + 2 * 128 * 1024 + 26_214_400 + 2 * (19_160 + 96) ); assert!(derived.total_bytes + (256_u64 << 20_u32) <= row.isolate_total_bytes); let mut underfunded = row; @@ -811,11 +812,11 @@ mod tests { // One sequential credential bank per admitted auction, not per call. assert_eq!( previous.total_bytes, - 2_355_338_744 + 2 * (1_576_960 + 128 * 1024) + 26_214_400 + 2 * 19_160 + 2_355_338_744 + 2 * (1_576_960 + 128 * 1024) + 26_214_400 + 2 * (19_160 + 96) ); assert_eq!( previous.per_auction_peak, - 565_731_420 + 1_576_960 + 128 * 1024 + 0x0008_0000 + 19_160 + 565_731_420 + 1_576_960 + 128 * 1024 + 0x0008_0000 + (19_160 + 96) ); assert_eq!(previous.bookkeeping_term, 917_504); assert_eq!( @@ -891,16 +892,16 @@ mod tests { assert_eq!(row.isolate_total_bytes, 4_u64 << 30_u32); assert_eq!( derived.total_bytes, - 2_536_754_680 + 2 * 128 * 1024 + 26_214_400 + 2 * 19_160 + 2_536_754_680 + 2 * 128 * 1024 + 26_214_400 + 2 * (19_160 + 96) ); assert_eq!( derived.per_auction_peak, - 654_862_428 + 1_576_960 + 128 * 1024 + 0x0008_0000 + 19_160 + 654_862_428 + 1_576_960 + 128 * 1024 + 0x0008_0000 + (19_160 + 96) ); assert_eq!(derived.bookkeeping_term, 3_276_800); assert_eq!( derived.margin_bytes, - 1_761_366_536 - 2 * (1_576_960 + 128 * 1024) - 26_214_400 - 2 * 19_160 + 1_761_366_536 - 2 * (1_576_960 + 128 * 1024) - 26_214_400 - 2 * (19_160 + 96) ); assert_eq!( derived.total_bytes, @@ -1278,7 +1279,7 @@ mod tests { ); assert_eq!( derived.total_bytes, - 2_536_384_248 + 2 * 128 * 1024 + 26_214_400 + 2 * 19_160 + 2_536_384_248 + 2 * 128 * 1024 + 26_214_400 + 2 * (19_160 + 96) ); assert_eq!( derived.margin_bytes, @@ -1287,7 +1288,7 @@ mod tests { assert_eq!(derived.auction_charges, expected_charges(&roomy)); assert_eq!( derived.auction_charges.privacy, - (14_u64 << 20_u32) + 800 + 32 + 19_160 + (14_u64 << 20_u32) + 800 + 32 + (19_160 + 96) ); assert_eq!(derived.runtime_config_term, 30_400_512); assert_eq!( @@ -1310,7 +1311,7 @@ mod tests { assert_eq!(derived.debug_term, 9_310_368); assert_eq!( derived.per_auction_peak, - 654_677_212 + 1_576_960 + 128 * 1024 + 0x0008_0000 + 19_160 + 654_677_212 + 1_576_960 + 128 * 1024 + 0x0008_0000 + (19_160 + 96) ); assert_eq!( u64::from(limits().max_outbound_requests) @@ -1321,7 +1322,7 @@ mod tests { ); assert_eq!( u64::from(limits().max_concurrent_auctions) * derived.per_auction_peak, - 1_309_354_424 + 2 * (1_576_960 + 128 * 1024 + 0x0008_0000 + 19_160), + 1_309_354_424 + 2 * (1_576_960 + 128 * 1024 + 0x0008_0000 + (19_160 + 96)), ); // The response node charge is independent of the privacy enforcement byte charge. assert_eq!( diff --git a/crates/auctioneer-adapter-axum/src/main.rs b/crates/auctioneer-adapter-axum/src/main.rs index 80727a4..86824f1 100644 --- a/crates/auctioneer-adapter-axum/src/main.rs +++ b/crates/auctioneer-adapter-axum/src/main.rs @@ -4,8 +4,8 @@ mod limits; mod resource_profile; use auctioneer_core::host_primitives::{ - BearerToken, DevRng, SystemClock, axum_production_strategy, bootstrap_adapter_with_envelope, - decide_health_policy, install_health_primitives, read_bool_flag, + DevRng, SystemClock, axum_production_strategy, bootstrap_adapter_with_envelope, + install_health_counter_scope, }; use auctioneer_runtime::NoopDecodeCooperator; use edgezero_adapter_axum::dev_server::run_app_with_preflight; @@ -14,9 +14,6 @@ use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_le use std::env; use std::sync::Arc; -const HEALTH_TOKEN_VAR: &str = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN"; -const ALLOW_UNAUTH_VAR: &str = "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH"; - fn main() -> anyhow::Result<()> { let mut args = env::args_os().skip(1); if let Some(arg) = args.next() { @@ -61,18 +58,8 @@ fn serve() -> anyhow::Result<()> { // has been installed, so there is nothing to serve with. The error // names the limit that failed. boot_result.map_err(|error| anyhow::anyhow!("boot envelope rejected: {error}"))?; - // Health-endpoint access policy, resolved ONCE from the process - // environment before the server accepts requests. Both inputs are - // boot-scoped: `HEALTH_POLICY` is a process-global installed once, - // so per-request `RuntimeConfig` can never carry them. With no - // token and no explicit opt-in the frozen `Closed` policy is - // installed and `/_auctioneer/health/gvl` answers 401 (fail-closed). - let raw_flag = env::var(ALLOW_UNAUTH_VAR).ok(); - let allow_unauth = read_bool_flag(ALLOW_UNAUTH_VAR, raw_flag.as_deref())?; - // An empty configured secret is a misconfiguration; `BearerToken:: - // new` maps it to "no token configured". - let token = env::var(HEALTH_TOKEN_VAR).ok().and_then(BearerToken::new); - install_health_primitives(decide_health_policy(token, allow_unauth)?, "process")?; + // Policy and secret resolution belongs to the shared health handler. + install_health_counter_scope("process")?; run_app_with_preflight::(|| { envelope::validate_sdk_settings(&EnvConfig::from_env()) }) @@ -133,8 +120,8 @@ mod logging_tests { fn assert_configured_levels(test_name: &str, oversized: bool) { const CHILD_LEVEL: &str = "AUCTIONEER__TEST__LOG_LEVEL"; if let Ok(expected) = env::var(CHILD_LEVEL) { - let error = super::serve().expect_err("invalid health flag stops before serving"); - assert!(error.to_string().contains(super::ALLOW_UNAUTH_VAR)); + let error = super::serve().expect_err("invalid SDK settings stop before serving"); + assert!(error.to_string().contains("max_connections")); let gates = boot_gates().expect("the actual shipped row bootstrapped"); assert!(gates.derived.total_bytes <= gates.limits.isolate_total_bytes); if oversized { @@ -172,9 +159,8 @@ mod logging_tests { command .args(["--exact", test_name, "--nocapture"]) .env(CHILD_LEVEL, expected.to_string()) - .env(super::ALLOW_UNAUTH_VAR, "invalid") - .env_remove("EDGEZERO__LOGGING__LEVEL") - .env_remove(super::HEALTH_TOKEN_VAR); + .env("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", "385") + .env_remove("EDGEZERO__LOGGING__LEVEL"); if let Some(level) = configured { command.env("EDGEZERO__LOGGING__LEVEL", level); } diff --git a/crates/auctioneer-adapter-axum/tests/README.md b/crates/auctioneer-adapter-axum/tests/README.md index 6aa540b..a9a33ca 100644 --- a/crates/auctioneer-adapter-axum/tests/README.md +++ b/crates/auctioneer-adapter-axum/tests/README.md @@ -18,6 +18,12 @@ production dependencies or a separate workspace package. auction through the handler, config loader, local bidder adapter and outbound client. Deny and allow controls check body disclosure, preserved context and signals, header isolation and stream-permit release. +- `resource_profile.rs` also starts the server executable with a synthetic + config-store envelope. Its health contract checks runtime policy, the standard + `EDGEZERO__SECRETS__HEALTH_GVL_TOKEN` SDK mapping, anonymous access, denied + requests with a Bearer challenge, and independent `/status` availability. + Authorized health may return 200 or 503 according to reference-data freshness; + the test checks its health payload in either case. Transport-only fixtures bind ephemeral loopback ports, with bounded waits. The privacy fixture requires `insecure-bidders` and binds the shipped local bidder's diff --git a/crates/auctioneer-adapter-axum/tests/health_boot.rs b/crates/auctioneer-adapter-axum/tests/health_boot.rs index 0c3fe52..7f524c7 100644 --- a/crates/auctioneer-adapter-axum/tests/health_boot.rs +++ b/crates/auctioneer-adapter-axum/tests/health_boot.rs @@ -1,82 +1,26 @@ -//! Boot source-resolution suite: the `(token, -//! allow_unauthenticated_health)` decision table the adapter runs at -//! boot over its env-resolved inputs. The table itself is -//! `auctioneer_core::host_primitives::decide_health_policy` — shared by -//! every adapter — so these tests exercise the exact decision `main` -//! installs, composed with the strict flag parse and the -//! empty-secret-rejecting token constructor, WITHOUT writing the -//! process-global policy `OnceLock` (the install itself is covered by -//! auctioneer-core's per-policy-state integration binaries). +//! Boot installs only the immutable observation scope. Policy belongs to config. #[cfg(test)] mod tests { - use auctioneer_core::host_primitives::{ - BearerToken, HealthAccessPolicy, HealthBootError, decide_health_policy, read_bool_flag, - }; - - const ALLOW_UNAUTH_VAR: &str = "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH"; - #[test] - fn env_token_installs_token_policy() { - let token = Some("secret").and_then(BearerToken::new); - let allow_unauth = read_bool_flag(ALLOW_UNAUTH_VAR, None).expect("absent flag is false"); - assert!(!allow_unauth); - let policy = decide_health_policy(token, allow_unauth).expect("token boot succeeds"); - match policy { - HealthAccessPolicy::Token(auth) => { - assert!(auth.verify("secret"), "the resolved token must verify"); - assert!(!auth.verify("wrong")); - } - HealthAccessPolicy::UnauthenticatedAcknowledged | HealthAccessPolicy::Closed => { - panic!("a resolved token must install the Token policy") + fn serving_and_baseline_boot_install_only_the_counter_scope() { + for source in [ + include_str!("../src/main.rs"), + include_str!("../src/baseline_probe.rs"), + ] { + assert!(source.contains("install_health_counter_scope(\"process\")?")); + for removed in [ + "decide_health_policy", + "install_health_primitives", + "read_bool_flag", + "HEALTH_TOKEN_VAR", + "ALLOW_UNAUTH_VAR", + ] { + assert!( + !source.contains(removed), + "boot must not read health policy: {removed}" + ); } } } - - #[test] - fn env_flag_true_no_token_installs_acknowledged() { - let allow_unauth = - read_bool_flag(ALLOW_UNAUTH_VAR, Some("true")).expect("strict true parses"); - assert!(allow_unauth); - let policy = decide_health_policy(None, allow_unauth).expect("acknowledged boot succeeds"); - assert!( - matches!(policy, HealthAccessPolicy::UnauthenticatedAcknowledged), - "flag-true with no token must install the explicit acknowledgement" - ); - // The flag parse is strict: anything but "true"/"false" is a - // boot error, never a silent false. - assert!(matches!( - read_bool_flag(ALLOW_UNAUTH_VAR, Some("yes")), - Err(HealthBootError::NonBooleanFlag { .. }) - )); - assert_eq!( - read_bool_flag(ALLOW_UNAUTH_VAR, Some(" true ")).ok(), - Some(true), - "surrounding whitespace is trimmed before the strict parse" - ); - } - - #[test] - fn token_plus_flag_is_boot_error() { - let token = BearerToken::new("secret"); - assert!( - matches!( - decide_health_policy(token, true), - Err(HealthBootError::ConflictingPolicy) - ), - "a token AND the unauthenticated opt-in is conflicting intent - boot must fail" - ); - } - - #[test] - fn neither_installs_no_policy() { - // An empty secret maps to "no token configured" first. - let token = Some("").and_then(BearerToken::new); - assert!(token.is_none()); - let policy = decide_health_policy(token, false).expect("fail-closed boot succeeds"); - assert!( - matches!(policy, HealthAccessPolicy::Closed), - "no token and no opt-in must freeze the fail-closed Closed policy" - ); - } } diff --git a/crates/auctioneer-adapter-axum/tests/resource_profile.rs b/crates/auctioneer-adapter-axum/tests/resource_profile.rs index e887694..b8fef6d 100644 --- a/crates/auctioneer-adapter-axum/tests/resource_profile.rs +++ b/crates/auctioneer-adapter-axum/tests/resource_profile.rs @@ -4,6 +4,7 @@ mod tests { use auctioneer_adapter_axum::envelope::resource_profile; use auctioneer_config_data::types::{BidderInfo, MediaType}; + use auctioneer_core::App as AuctioneerApp; use auctioneer_core::config::{AuctioneerAppConfig, SdkAuctioneerAppConfig}; use auctioneer_core::host_primitives::{boot_gates, install_boot_envelope}; use auctioneer_privacy::gvl::GVL_PARSE_CACHE_MAX_ENTRIES; @@ -15,6 +16,7 @@ mod tests { config_store_limits::ConfigStoreLimits, ingress_config::AxumIngressConfig, }; use edgezero_cli::args::ConfigPushArgs; + use edgezero_core::app::Hooks as _; use edgezero_core::blob_envelope::BlobEnvelope; use serde_json::value::RawValue; use serde_json::{Value, json}; @@ -82,8 +84,7 @@ mod tests { let mut command = self.command(); command .arg("--resource-profile") - .env("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN", CANARY) - .env("AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH", CANARY) + .env("EDGEZERO__SECRETS__HEALTH_GVL_TOKEN", CANARY) .env("EDGEZERO__SECRETS__USERSYNC_COOKIE_KEYRING", CANARY) .env("EDGEZERO__LOGGING__LEVEL", "trace") .env("EDGEZERO__ADAPTER__HOST", CANARY) @@ -699,6 +700,10 @@ mod tests { impl Server { fn start(fixture: &Fixture) -> Self { + Self::start_with_settings(fixture, &[]) + } + + fn start_with_settings(fixture: &Fixture, settings: &[(&str, &str)]) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); drop(listener); @@ -708,6 +713,7 @@ mod tests { .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") .env("EDGEZERO__ADAPTER__PORT", addr.port().to_string()) .env("EDGEZERO__LOGGING__LEVEL", "off") + .envs(settings.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::null()); let mut server = Self { @@ -733,6 +739,15 @@ mod tests { } fn request(&self, path: &str, body: &[u8]) -> (u16, String) { + self.request_with_bearer(path, body, None) + } + + fn request_with_bearer( + &self, + path: &str, + body: &[u8], + bearer: Option<&str>, + ) -> (u16, String) { let mut stream = TcpStream::connect_timeout(&self.addr, Duration::from_secs(2)).unwrap(); stream @@ -742,7 +757,11 @@ mod tests { .set_write_timeout(Some(Duration::from_secs(2))) .unwrap(); let method = if body.is_empty() { "GET" } else { "POST" }; - write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len()).unwrap(); + write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n", body.len()).unwrap(); + if let Some(presented) = bearer { + write!(stream, "Authorization: Bearer {presented}\r\n").unwrap(); + } + write!(stream, "\r\n").unwrap(); stream.write_all(body).unwrap(); let mut bytes = Vec::new(); stream.take(0x8001).read_to_end(&mut bytes).unwrap(); @@ -750,6 +769,12 @@ mod tests { let response = String::from_utf8(bytes).unwrap(); let (head, response_body) = response.split_once("\r\n\r\n").unwrap(); let status = head.split_whitespace().nth(1).unwrap().parse().unwrap(); + if path == "/_auctioneer/health/gvl" && status == 401 { + assert!( + head.to_ascii_lowercase() + .contains("www-authenticate: bearer") + ); + } if path == "/status" { assert!( head.to_ascii_lowercase() @@ -811,15 +836,15 @@ mod tests { String::from_utf8_lossy(&stderr) ); assert!(String::from_utf8_lossy(&stdout).contains("running 1 test")); + let config_id = AuctioneerApp::stores().config.unwrap().default; let store_bytes = fs::read( fixture .0 - .join(".edgezero/local-config-auctioneer_runtime.json"), + .join(format!(".edgezero/local-config-{config_id}.json")), ) .unwrap(); let store: Value = serde_json::from_slice(&store_bytes).unwrap(); - let blob: BlobEnvelope = - serde_json::from_str(store["auctioneer_runtime"].as_str().unwrap()).unwrap(); + let blob: BlobEnvelope = serde_json::from_str(store[config_id].as_str().unwrap()).unwrap(); blob.verify().unwrap(); assert_eq!(blob.data["schema_version"], 1_u8); assert_eq!(blob.data["host"]["status_response"], banner); @@ -917,6 +942,80 @@ mod tests { ); } + #[test] + fn health_uses_runtime_policy_and_the_standard_sdk_secret_environment() { + const TOKEN: &str = "fictional-sdk-health-token"; + const HEALTH: &str = "/_auctioneer/health/gvl"; + for (policy, sdk_token, expected) in [ + (None, Some(TOKEN), 401_u16), + (Some(json!({"enabled": false})), None, 200_u16), + ( + Some(json!({"enabled": true, "token_secret": "health_gvl_token"})), + Some(TOKEN), + 200_u16, + ), + ( + Some(json!({"enabled": true, "token_secret": "health_gvl_token"})), + None, + 401_u16, + ), + ] { + let fixture = Fixture::new(); + let mut data = json!({"schema_version": 1_u8}); + if let Some(configured_policy) = policy { + data["gvl"] = json!({"authenticated_health": configured_policy}); + } + let envelope = BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned()); + let config_id = AuctioneerApp::stores().config.unwrap().default; + DirBuilder::new() + .create(fixture.0.join(".edgezero")) + .unwrap(); + fs::write( + fixture + .0 + .join(format!(".edgezero/local-config-{config_id}.json")), + serde_json::to_vec( + &json!({(config_id): serde_json::to_string(&envelope).unwrap()}), + ) + .unwrap(), + ) + .unwrap(); + let settings: Vec<_> = sdk_token + .into_iter() + .map(|token| ("EDGEZERO__SECRETS__HEALTH_GVL_TOKEN", token)) + .collect(); + let server = Server::start_with_settings(&fixture, &settings); + let authenticated = server.request_with_bearer(HEALTH, b"", Some(TOKEN)); + if expected == 200 { + assert!(matches!(authenticated.0, 200 | 503)); + let payload: Value = serde_json::from_str(&authenticated.1).unwrap(); + assert_eq!(payload["counter_scope"], "process"); + assert!(matches!(payload["status"].as_str(), Some("ok" | "stale"))); + } else { + assert_eq!(authenticated.0, 401); + } + assert_eq!( + server + .request_with_bearer(HEALTH, b"", Some("wrong-token")) + .0, + if expected == 200 && sdk_token.is_none() { + authenticated.0 + } else { + 401 + } + ); + assert_eq!( + server.request(HEALTH, b"").0, + if expected == 200 && sdk_token.is_none() { + authenticated.0 + } else { + 401 + } + ); + assert_eq!(server.request("/status", b"").0, 204); + } + } + #[test] fn unknown_arguments_fail_with_a_fixed_diagnostic() { let fixture = Fixture::new(); @@ -925,10 +1024,7 @@ mod tests { vec!["--resource-profile", CANARY], vec!["--resource-profile", "--resource-profile"], ] { - let (status, stdout, stderr) = fixture.run(fixture.command().args(args).env( - "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH", - "invalid", - )); + let (status, stdout, stderr) = fixture.run(fixture.command().args(args)); assert!(!status.success()); assert!(stdout.is_empty()); assert_eq!( diff --git a/crates/auctioneer-adapter-cloudflare/src/health.rs b/crates/auctioneer-adapter-cloudflare/src/health.rs index 47c006b..d085a35 100644 --- a/crates/auctioneer-adapter-cloudflare/src/health.rs +++ b/crates/auctioneer-adapter-cloudflare/src/health.rs @@ -1,33 +1,9 @@ -//! Health-only degradation shared by production fetches and host regressions. +//! Adapters own only the immutable health counter scope. -use auctioneer_core::host_primitives::{ - BearerToken, HealthAccessPolicy, HealthBootError, decide_health_policy, - install_health_primitives, read_bool_flag, -}; +use auctioneer_core::host_primitives::{HealthBootError, install_health_counter_scope}; -pub(super) const FLAG_VARIABLE: &str = "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH"; - -/// Resolution faults close health without stopping routing. Installation -/// conflicts remain fatal because an isolate's frozen policy cannot change. -pub(super) fn install( - raw_flag: Option<&str>, - raw_token: Option, -) -> Result<(), HealthBootError> { - let resolved = read_bool_flag(FLAG_VARIABLE, raw_flag).and_then(|allow_unauth| { - decide_health_policy(raw_token.and_then(BearerToken::new), allow_unauth) - }); - let policy = match resolved { - Ok(policy) => policy, - Err(err) => { - log::error!( - target: "auctioneer::health", - "health policy unresolved ({err}); serving with health CLOSED so \ - /status and auctions remain available" - ); - HealthAccessPolicy::Closed - } - }; - install_health_primitives(policy, "isolate") +pub(super) fn install() -> Result<(), HealthBootError> { + install_health_counter_scope("isolate") } #[cfg(test)] @@ -36,13 +12,14 @@ mod tests { use crate::envelope; use auctioneer_core::App as AuctioneerApp; use auctioneer_core::host_primitives::{ - DelegateToDevStrategy, DevRng, HealthAccessPolicy, HealthAuthz, HealthBootError, - SystemClock, bootstrap_adapter_with_envelope, counter_scope_handle, health_authorize, - health_policy_handle, install_health_primitives, install_stderr_boot_logger_with_level, + DelegateToDevStrategy, DevRng, HealthBootError, SystemClock, TestClockHandle, + bootstrap_adapter_with_envelope, counter_scope_handle, install_health_counter_scope, + install_stderr_boot_logger_with_level, }; - use auctioneer_runtime::{ImmediateResolveTimer, NoopDecodeCooperator}; + use auctioneer_runtime::{FakeClock, ImmediateResolveTimer, NoopDecodeCooperator}; use edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM; use edgezero_core::app::App; + use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, ConfigValue, @@ -66,75 +43,26 @@ mod tests { use std::process::Command; use std::rc::Rc; use std::sync::Arc; + use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; const CHILD: &str = "AUCTIONEER__TEST__CLOUDFLARE_HEALTH_CHILD"; const TOKEN: &str = "health-token-must-not-appear-in-logs"; - const FLAG_TOKEN: &str = "flagHeadF8v3.flagBodyJ7m2.flagSigQ5n9"; - const PADDED_FLAG_TOKEN: &str = " \tflagHeadF8v3.flagBodyJ7m2.flagSigQ5n9\n"; - - fn assert_no_health_credentials(rendered: &str) { - for sensitive in [ - TOKEN, - "health-token", - "must-not-appear-in-logs", - FLAG_TOKEN, - "flagHeadF8v3", - "flagBodyJ7m2", - "flagSigQ5n9", - "F8v3", - "J7m2", - "Q5n9", - ] { - assert!( - !rendered.contains(sensitive), - "health credentials and their segments must not be exposed" - ); - } - } - #[test] - fn rejected_health_flags_preserve_key_and_type_without_raw_values() { - for raw_flag in [ - FLAG_TOKEN, - PADDED_FLAG_TOKEN, - TOKEN, - "not-a-boolean", - "TRUE", - "", - ] { - let error = super::read_bool_flag(super::FLAG_VARIABLE, Some(raw_flag)) - .expect_err("a non-boolean flag must be rejected"); - assert!(matches!( - &error, - HealthBootError::NonBooleanFlag { key } if *key == super::FLAG_VARIABLE - )); - let display = error.to_string(); - let debug = format!("{error:?}"); - assert!(display.contains("not a strict boolean")); - assert!(debug.contains("NonBooleanFlag")); - for rendered in [display, debug] { - assert!(rendered.contains(super::FLAG_VARIABLE)); - assert_no_health_credentials(&rendered); - if !raw_flag.trim().is_empty() { - assert!( - !rendered.contains(raw_flag.trim()), - "neither Display nor Debug may expose the rejected flag" - ); - } + fn config_envelope(case: &str) -> String { + let mut data = json!({"schema_version": 1_u8}); + match case { + "anonymous" => data["gvl"] = json!({"authenticated_health": {"enabled": false}}), + "missing-secret" => { + data["gvl"] = json!({"authenticated_health": { + "enabled": true, "token_secret": "health_gvl_token" + }}); + } + "malformed-config" => { + data["gvl"] = json!({"authenticated_health": {"enabled": "true"}}); } + _ => {} } - } - - // A valid envelope over the minimal versioned policy keeps application - // configuration independent of the health inputs being exercised. - fn config_envelope() -> String { - json!({ - "data": {"schema_version": 1_u8}, - "sha256": "a9d5f6d002d956b8af5787a05e0ca000d45c03977ffa54ee8fbed719fed5fd23", - "version": 1_u32, - "generated_at": "1970-01-01T00:00:00Z", - }) - .to_string() + serde_json::to_string(&BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned())).unwrap() } // SDK request conversion supplies this extension before app dispatch. @@ -175,13 +103,13 @@ mod tests { } } - struct HealthyConfig; + struct HealthyConfig(&'static str); #[async_trait::async_trait(?Send)] impl ConfigStore for HealthyConfig { async fn get(&self, key: &str) -> Result, ConfigStoreError> { assert_eq!(key, "auctioneer_runtime"); - Ok(Some(config_envelope().into())) + Ok(Some(config_envelope(self.0).into())) } async fn get_bounded( @@ -194,7 +122,7 @@ mod tests { ) -> Result, ConfigStoreError> { assert_eq!(key, "auctioneer_runtime"); finish_bounded_config_read( - Ok(Some(config_envelope())), + Ok(Some(config_envelope(self.0))), clock, deadline, max_backend_bytes, @@ -203,15 +131,33 @@ mod tests { } } - fn dispatch(app: &App, mut request: Request) -> Box { + fn dispatch( + app: &App, + mut request: Request, + case: &'static str, + ) -> Box { let config: ConfigRegistry = StoreRegistry::single_id( "auctioneer_runtime".to_owned(), ConfigStoreBinding { default_key: "auctioneer_runtime".to_owned(), - handle: ConfigStoreHandle::new(Arc::new(HealthyConfig)), + handle: ConfigStoreHandle::new(Arc::new(HealthyConfig(case))), }, ); - request.extensions_mut().insert(config); + if case != "missing-config" { + request.extensions_mut().insert(config); + } + let generated = + OffsetDateTime::parse(auctioneer_config_data::gvl_archive_generated_at(), &Rfc3339) + .unwrap(); + let expires = + OffsetDateTime::from_unix_timestamp(auctioneer_config_data::gcl_expires_at_unix_secs()) + .unwrap(); + let now = generated + .saturating_add(Duration::days(1)) + .min(expires.saturating_sub(Duration::hours(1))); + request + .extensions_mut() + .insert(TestClockHandle(Arc::new(FakeClock::new(now)))); match block_on(app.dispatch_ingress( request, MonotonicInstant::now(), @@ -228,7 +174,7 @@ mod tests { } } - fn assert_closed_routes_still_dispatch() { + fn assert_routes_still_dispatch(case: &'static str) { let limits = envelope::limits(); let target = envelope::target(); bootstrap_adapter_with_envelope( @@ -247,16 +193,25 @@ mod tests { .uri("/status") .body(Body::empty()) .unwrap(); - let mut status = dispatch(&app, status_request); + let mut status = dispatch(&app, status_request, case); assert!(matches!( status.response_mut().status(), StatusCode::OK | StatusCode::NO_CONTENT )); assert_eq!( status.response_mut().headers()["x-auctioneer-config-status"], - "ok", - "a health-only fault must not change the application config verdict" + if matches!(case, "missing-config" | "malformed-config") { + "degraded" + } else { + "ok" + } ); + if matches!(case, "missing-config" | "malformed-config") { + assert_eq!( + status.response_mut().body().as_bytes().unwrap(), + b"config: degraded (malformed_config)\n" + ); + } drop(status); let mut auction_request = request_builder() @@ -272,7 +227,7 @@ mod tests { auction_request .extensions_mut() .insert(HttpClient::with_client(ValidationOnlyClient)); - let mut auction = dispatch(&app, auction_request); + let mut auction = dispatch(&app, auction_request, case); let response = auction.response_mut(); let body = response .body() @@ -280,10 +235,17 @@ mod tests { .expect("auction validation returns a buffered error body"); assert_eq!( response.status(), - StatusCode::BAD_REQUEST, - "the auction reaches its own validation rather than failing health boot; body: {}", + if matches!(case, "missing-config" | "malformed-config") { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::BAD_REQUEST + }, + "the auction reaches its own config or validation; body: {}", String::from_utf8_lossy(body) ); + if matches!(case, "missing-config" | "malformed-config") { + assert_eq!(body, b"Malformed host configuration"); + } drop(auction); for bearer in [None, Some("Bearer health-token-must-not-appear-in-logs")] { @@ -291,155 +253,68 @@ mod tests { if let Some(value) = bearer { health_builder = health_builder.header("authorization", value); } - let mut health = dispatch(&app, health_builder.body(Body::empty()).unwrap()); - assert_eq!(health.response_mut().status(), StatusCode::UNAUTHORIZED); - assert_eq!( - health.response_mut().headers()["www-authenticate"], - "Bearer" - ); - } - } - - fn scenario_inputs(case: &str) -> (Option<&'static str>, Option<&'static str>) { - match case { - "malformed" => (Some("not-a-boolean"), None), - "malformed-with-token" => (Some("TRUE"), Some(TOKEN)), - "token-in-flag" => (Some(FLAG_TOKEN), None), - "padded-token-in-flag" => (Some(PADDED_FLAG_TOKEN), None), - "token-in-flag-with-token" => (Some(FLAG_TOKEN), Some(TOKEN)), - "blank" => (Some(""), None), - "conflicting" => (Some("true"), Some(TOKEN)), - "absent" => (None, None), - "default" => (Some("false"), None), - "empty-token" => (Some("false"), Some("")), - "open" | "installed-open" => (Some(" true "), None), - "token" | "installed-token" => (Some("false"), Some(TOKEN)), - _ => panic!("unknown health scenario"), - } - } - - fn assert_scope_conflict_preserves_policy() { - install_health_primitives(HealthAccessPolicy::Closed, "other").unwrap(); - assert!(matches!( - super::install(Some("invalid"), None), - Err(HealthBootError::ConflictingPolicy) - )); - assert_eq!(counter_scope_handle(), "other"); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - } - - fn assert_installed_policy_preserved(case: &str) { - assert!(matches!( - super::install(Some("invalid"), None), - Err(HealthBootError::ConflictingPolicy) - )); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Allow)); - assert_eq!( - health_authorize(None), - if case == "installed-open" { - HealthAuthz::Allow + let mut health = dispatch(&app, health_builder.body(Body::empty()).unwrap(), case); + if case == "anonymous" { + assert_eq!(health.response_mut().status(), StatusCode::OK); } else { - HealthAuthz::Deny - }, - "a failed fallback preserves the previously installed policy" - ); - } - - fn assert_closed_policy_cannot_be_replaced() { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Closed) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - assert_closed_routes_still_dispatch(); - for (replacement_flag, replacement_token) in - [(Some("true"), None), (Some("false"), Some(TOKEN))] - { - assert!(matches!( - super::install(replacement_flag, replacement_token.map(str::to_owned)), - Err(HealthBootError::ConflictingPolicy) - )); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - } - } - - fn assert_resolved_policy(case: &str) { - match case { - "open" => { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::UnauthenticatedAcknowledged) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Allow)); - } - "token" => { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Token(_)) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some("wrong")), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Allow)); + assert_eq!(health.response_mut().status(), StatusCode::UNAUTHORIZED); + assert_eq!( + health.response_mut().headers()["www-authenticate"], + "Bearer" + ); } - _ => assert_closed_policy_cannot_be_replaced(), } } - // The policy and counter scope are process-global OnceLocks. Each - // scenario needs its own process, including repeated Worker fetches. + // The counter scope is process-global; each independent case needs a child. #[test] - fn health_policy_child() { + fn health_scope_child() { let Ok(case) = env::var(CHILD) else { return; }; assert!(install_stderr_boot_logger_with_level(LevelFilter::Info)); - assert!(health_policy_handle().is_none()); - if case == "installed-scope" { - assert_scope_conflict_preserves_policy(); + if case == "scope-conflict" { + install_health_counter_scope("other").unwrap(); + assert!(matches!( + super::install(), + Err(HealthBootError::ConflictingScope) + )); + assert_eq!(counter_scope_handle(), "other"); return; } - let (flag, token) = scenario_inputs(&case); - super::install(flag, token.map(str::to_owned)) - .expect("bad health input degrades, but does not fail request boot"); - super::install(flag, token.map(str::to_owned)).expect("repeat installs are idempotent"); + super::install().expect("counter scope has no config or secret prerequisite"); + super::install().expect("same scope installs idempotently"); assert_eq!(counter_scope_handle(), "isolate"); - - if case.starts_with("installed-") { - assert_installed_policy_preserved(&case); - } else { - assert_resolved_policy(&case); - } + let scenario = match case.as_str() { + "absent" => "absent", + "anonymous" => "anonymous", + "missing-secret" => "missing-secret", + "missing-config" => "missing-config", + "malformed-config" => "malformed-config", + _ => panic!("unknown scenario"), + }; + assert_routes_still_dispatch(scenario); } #[test] - fn health_faults_close_only_health_and_preserve_frozen_policy() { - for (case, diagnostics) in [ - ("malformed", 2), - ("malformed-with-token", 2), - ("token-in-flag", 2), - ("padded-token-in-flag", 2), - ("token-in-flag-with-token", 2), - ("blank", 2), - ("conflicting", 2), - ("absent", 0), - ("default", 0), - ("empty-token", 0), - ("open", 0), - ("token", 0), - ("installed-open", 1), - ("installed-token", 1), - ("installed-scope", 1), + fn runtime_policy_controls_health_without_provider_boot_credentials() { + for case in [ + "absent", + "anonymous", + "missing-secret", + "missing-config", + "malformed-config", + "scope-conflict", ] { - let output = Command::new(env::current_exe().expect("test binary")) + let output = Command::new(env::current_exe().unwrap()) .args([ "--exact", - "health::tests::health_policy_child", + "health::tests::health_scope_child", "--nocapture", ]) .env(CHILD, case) .output() - .expect("isolated health process"); + .unwrap(); let logs = format!( "{}{}", String::from_utf8_lossy(&output.stdout), @@ -447,42 +322,10 @@ mod tests { ); assert!(output.status.success(), "{case}: {logs}"); assert!(logs.contains("1 passed"), "the child must run: {logs}"); - assert_eq!( - logs.matches("health policy unresolved").count(), - diagnostics, - "{case}: malformed input is diagnosed, not silently ignored: {logs}" + assert!( + !logs.contains(TOKEN), + "health credentials must not appear in logs" ); - for diagnostic in logs - .lines() - .filter(|line| line.contains("health policy unresolved")) - { - assert!(diagnostic.contains("ERROR auctioneer::health:")); - if case != "conflicting" { - assert!( - diagnostic.contains(super::FLAG_VARIABLE), - "{case}: the rejected flag key must remain diagnosable: {logs}" - ); - assert!( - diagnostic.contains("not a strict boolean"), - "{case}: the wrong-type reason must remain diagnosable: {logs}" - ); - } - } - assert_no_health_credentials(&logs); - if matches!( - case, - "malformed" - | "malformed-with-token" - | "token-in-flag" - | "padded-token-in-flag" - | "token-in-flag-with-token" - ) { - let (flag, _) = scenario_inputs(case); - assert!( - !logs.contains(flag.expect("malformed flag scenario").trim()), - "the process logger must not expose the rejected flag" - ); - } } } } diff --git a/crates/auctioneer-adapter-cloudflare/src/lib.rs b/crates/auctioneer-adapter-cloudflare/src/lib.rs index 6395021..b93a23a 100644 --- a/crates/auctioneer-adapter-cloudflare/src/lib.rs +++ b/crates/auctioneer-adapter-cloudflare/src/lib.rs @@ -58,10 +58,10 @@ mod tests { /// Boot the Worker isolate and dispatch a request through the application. /// /// # Errors -/// Returns an error for invalid boot limits, conflicting health-policy +/// Returns an error for invalid boot limits, conflicting health counter-scope /// installation or request/response dispatch. The SDK delivers application /// assembly failures as typed HTTP error responses through its owned egress path. -/// Health configuration resolution errors degrade to a closed health policy. +/// Health policy and credentials are resolved by the shared request handler. #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { @@ -101,30 +101,13 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { || Arc::new(DelegateToDevStrategy), ) .map_err(|e| Error::RustError(format!("auctioneer boot envelope rejected: {e}")))?; - install_health(&env)?; + install_health()?; run_app::(req, env, ctx).await } -/// Resolve the health-endpoint access policy from the Worker -/// environment and install it. Runs on every fetch; the install is -/// idempotent for identical inputs, and the first fetch freezes the -/// policy `OnceLock` — including the fail-closed `Closed` policy when -/// neither input resolves — so a later fetch cannot flip the endpoint -/// open. -/// -/// Invalid health configuration installs `Closed` without stopping -/// `/status` or auctions, matching Fastly. An installation conflict -/// still fails the fetch: a frozen policy must never be ignored. +/// Install the isolate's immutable health observation scope. #[cfg(target_arch = "wasm32")] -fn install_health(env: &Env) -> Result<()> { - const TOKEN_SECRET: &str = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN"; - // A missing secret / var reads as absent; the decision table - // fail-closes on (no token, no opt-in). - let raw_flag = env - .var(health::FLAG_VARIABLE) - .ok() - .map(|value| value.to_string()); - let raw_token = env.secret(TOKEN_SECRET).ok().map(|value| value.to_string()); - health::install(raw_flag.as_deref(), raw_token) - .map_err(|err| Error::RustError(format!("health policy install conflicted: {err}"))) +fn install_health() -> Result<()> { + health::install() + .map_err(|err| Error::RustError(format!("health counter scope install conflicted: {err}"))) } diff --git a/crates/auctioneer-adapter-cloudflare/tests/manifest_runtime_variables.rs b/crates/auctioneer-adapter-cloudflare/tests/manifest_runtime_variables.rs index 1ad0900..4b6d257 100644 --- a/crates/auctioneer-adapter-cloudflare/tests/manifest_runtime_variables.rs +++ b/crates/auctioneer-adapter-cloudflare/tests/manifest_runtime_variables.rs @@ -6,8 +6,7 @@ mod tests { use edgezero_core::app::Hooks; use toml::{Table, Value, from_str}; - const ALLOW_UNAUTH: &str = "AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH"; - const TOKEN_SECRET: &str = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN"; + const TOKEN_SECRET: &str = "health_gvl_token"; fn variables() -> Table { let manifest: Value = from_str(include_str!("../wrangler.toml")) @@ -26,7 +25,6 @@ mod tests { "EDGEZERO__ADAPTER__HOST", "EDGEZERO__ADAPTER__PORT", "EDGEZERO__LOGGING__LEVEL", - ALLOW_UNAUTH, ] { assert!( variables.get(name).and_then(Value::as_str).is_some(), @@ -65,15 +63,30 @@ mod tests { fn logging_and_health_have_safe_defaults_and_the_token_is_a_secret() { let variables = variables(); assert_eq!(variables["EDGEZERO__LOGGING__LEVEL"].as_str(), Some("INFO")); - assert_eq!(variables[ALLOW_UNAUTH].as_str(), Some("false")); + assert!(!variables.contains_key("AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH")); assert!( !variables.contains_key(TOKEN_SECRET), "a credential must never be shipped as a plaintext [vars] default" ); assert!( - include_str!("../wrangler.toml") - .contains("wrangler secret put AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN"), + include_str!("../wrangler.toml").contains("wrangler secret put health_gvl_token"), "the secret binding must be documented without embedding a token" ); } + #[test] + fn worker_boot_does_not_resolve_health_policy_or_credentials() { + let source = include_str!("../src/lib.rs"); + assert!(source.contains("install_health()?")); + for removed in [ + "env.secret(", + "health::FLAG_VARIABLE", + "raw_token", + "raw_flag", + ] { + assert!( + !source.contains(removed), + "provider boot must not resolve {removed}" + ); + } + } } diff --git a/crates/auctioneer-adapter-cloudflare/wrangler.toml b/crates/auctioneer-adapter-cloudflare/wrangler.toml index 1ec599c..a90a299 100644 --- a/crates/auctioneer-adapter-cloudflare/wrangler.toml +++ b/crates/auctioneer-adapter-cloudflare/wrangler.toml @@ -7,7 +7,6 @@ compatibility_flags = ["enable_weak_ref"] # non-secret key queried by auctioneer and the pinned EdgeZero reader explicit. [vars] EDGEZERO__LOGGING__LEVEL = "INFO" -AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH = "false" EDGEZERO__STORES__CONFIG__AUCTIONEER_CONFIG__NAME = "auctioneer_config" EDGEZERO__STORES__CONFIG__AUCTIONEER_CONFIG__KEY = "auctioneer_config" EDGEZERO__STORES__SECRETS__AUCTIONEER_SECRETS__NAME = "auctioneer_secrets" @@ -17,9 +16,9 @@ EDGEZERO__ADAPTER__HOST = "" EDGEZERO__ADAPTER__PORT = "" # Optional credential, never a plaintext [vars] default: -# wrangler secret put AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN -# Set a token OR opt into unauthenticated health, not both. With neither -# configured (or invalid health configuration), only health stays closed. +# wrangler secret put health_gvl_token +# Select this literal entry with gvl.authenticated_health.token_secret in +# auctioneer.toml. Health stays closed when the section is omitted. # Cookie signing uses the existing default logical secret-store binding. # CloudflareSecretStore calls Env::secret(key) literally, without case conversion: diff --git a/crates/auctioneer-adapter-fastly/Cargo.toml b/crates/auctioneer-adapter-fastly/Cargo.toml index c530429..f872dc6 100644 --- a/crates/auctioneer-adapter-fastly/Cargo.toml +++ b/crates/auctioneer-adapter-fastly/Cargo.toml @@ -59,6 +59,7 @@ auctioneer-config-data = { workspace = true } auctioneer-exchange = { workspace = true } time = { workspace = true } toml = { workspace = true } +serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] edgezero-adapter-fastly = { workspace = true, features = ["fastly"] } diff --git a/crates/auctioneer-adapter-fastly/src/lib.rs b/crates/auctioneer-adapter-fastly/src/lib.rs index 203a0fc..70af1e6 100644 --- a/crates/auctioneer-adapter-fastly/src/lib.rs +++ b/crates/auctioneer-adapter-fastly/src/lib.rs @@ -10,8 +10,7 @@ //! [`timer`], [`cooperator`], [`delay`] and [`limits`] compile on every //! target: the profile is plain data and the two primitives are logic //! over a [`delay::DelaySource`], so `tests/fanout.rs` exercises all of -//! it on the host. The `fastly` SDK calls — the store reads in -//! `install_health` and the dispatch handoff are wasm-gated. The process +//! it on the host. The Fastly SDK dispatch handoff is wasm-gated. Process //! tests in `tests/boot_logging.rs` exercise the real guest in Viceroy. pub mod cooperator; @@ -35,8 +34,8 @@ mod config_validation_tests { use auctioneer_core::App as AuctioneerApp; #[cfg(target_arch = "wasm32")] use auctioneer_core::host_primitives::{ - DelegateToDevStrategy, DevRng, HealthAccessPolicy, SystemClock, - bootstrap_adapter_with_envelope, install_stderr_boot_logger_with_level, + DelegateToDevStrategy, DevRng, SystemClock, bootstrap_adapter_with_envelope, + install_stderr_boot_logger_with_level, }; #[cfg(target_arch = "wasm32")] use edgezero_adapter_fastly::{FastlyLogging, init_logger, run_app}; @@ -54,14 +53,14 @@ use std::sync::Arc; /// App assembly uses the generated `app!` hooks and Fastly platform metadata. /// `EdgeZero`'s manifest-aware dispatch helper owns request conversion, /// registries, routing and response transmission. Logging is baked into -/// the generated hooks; health and dispatch open the same logical store -/// aliases, bound to physical resources at deployment. +/// the generated hooks; SDK registries bind logical store aliases to +/// physical resources at deployment. /// /// # Errors -/// Propagates invalid boot limits, conflicting health-policy installation, +/// Propagates invalid boot limits, conflicting health counter-scope installation, /// or request/response dispatch errors. The SDK delivers application assembly /// failures as typed HTTP error responses through its owned egress path. -/// Missing health stores install a closed policy rather than stopping routing. +/// Health policy and secret faults affect the shared health handler only. #[cfg(target_arch = "wasm32")] pub fn run() -> Result<(), Error> { run_with_hooks::() @@ -154,225 +153,112 @@ fn install_boot_log() { } } -/// Install the health-endpoint access policy, falling back to -/// [`HealthAccessPolicy::Closed`] when the stores cannot be read. -/// -/// A store fault must NOT stop routing. `/status` exists to report -/// that this deployment is unhealthy, and propagating the fault out of -/// boot took the router down with it — so the one endpoint that could -/// have named the problem never answered, and an operator saw a dead -/// service rather than a degraded one. -/// -/// Health authorization stays CLOSED in that case rather than opening: -/// an unreadable store is not permission to serve the GVL endpoint -/// unauthenticated. `Closed` answers 401, which is attributable. -/// -/// The fault is logged at ERROR so Compute stderr or the Fastly log -/// endpoint carries it, and `/status` reports the config verdict -/// through its own resolution path. +/// Install the request's immutable health observation scope. #[cfg(target_arch = "wasm32")] fn install_health() -> Result<(), Error> { - use auctioneer_core::host_primitives::install_health_primitives; - - match resolve_health_policy() { - Ok(policy) => install_health_primitives(policy, "request") - .map_err(|err| Error::msg(format!("health policy install conflicted: {err}"))), - Err(err) => { - log::error!( - target: "auctioneer::health", - "health policy unresolved ({err}); serving with health CLOSED so \ - /status can report the fault" - ); - install_health_primitives(HealthAccessPolicy::Closed, "request").map_err(|install| { - Error::msg(format!("health policy install conflicted: {install}")) - }) - } - } -} - -/// Read the policy out of the Fastly stores. -/// -/// The bearer token is a credential → `SecretStore` (encrypted at -/// rest); the non-secret `allow_unauthenticated_health` flag stays in -/// the `ConfigStore`. EVERY Fastly call is the fallible variant — -/// the infallible store-open and plaintext APIs panic on failure — -/// so a missing or broken store returns an error here instead of -/// panicking, and the caller decides what to serve. -#[cfg(target_arch = "wasm32")] -fn resolve_health_policy() -> Result { - use auctioneer_core::host_primitives::{BearerToken, decide_health_policy, read_bool_flag}; - use fastly::config_store::ConfigStore; - use fastly::secret_store::SecretStore; - // The store NAMES come from the manifest's `[stores.*]` - // declarations, which `app!` bakes into `Hooks::stores()` — not from - // a literal here. A literal is how boot and provisioning came to - // disagree: opening an unrelated hardcoded alias failed health installation - // before routing could answer even `/status`. - // - // Fail closed when a declaration is absent rather than falling back - // to a guess: a missing `[stores.*]` entry means the deployment was - // never told to provision one, and inventing a name would put the - // same disagreement back with a different literal. - let stores = ::stores(); - let config_id = stores - .config - .ok_or_else(|| Error::msg("edgezero.toml declares no [stores.config]"))? - .default; - let secrets_id = stores - .secrets - .ok_or_else(|| Error::msg("edgezero.toml declares no [stores.secrets]"))? - .default; - // Fastly resource links bind physical stores under these logical - // aliases at deployment. The SDK registries open the same aliases; - // runtime environment remapping would make health read a different store. - let raw_flag = ConfigStore::try_open(config_id)?.try_get("allow_unauthenticated_health")?; - let allow_unauth = read_bool_flag("allow_unauthenticated_health", raw_flag.as_deref())?; - let secret = SecretStore::open(secrets_id)?.try_get("health_gvl_token")?; - let token_bytes = match secret { - Some(s) => Some(s.try_plaintext()?), - None => None, - }; - // An empty secret reads as "no token configured". - let token = token_bytes.and_then(BearerToken::new); - let policy = decide_health_policy(token, allow_unauth) - .map_err(|err| Error::msg(format!("health policy rejected: {err}")))?; - Ok(policy) + auctioneer_core::host_primitives::install_health_counter_scope("request") + .map_err(|err| Error::msg(format!("health counter scope install conflicted: {err}"))) } #[cfg(test)] #[cfg(target_arch = "wasm32")] mod health_setup_tests { - //! Real SDK reads against the shipped local manifest, in fresh Viceroy guests. + //! Real shared health routes through SDK registry assembly and terminal egress. - use super::{AuctioneerApp, HealthAccessPolicy, Hooks, resolve_health_policy}; + use super::{ + AuctioneerApp, cooperator::FastlyDecodeCooperator, delay::ImmediateDelaySource, + timer::FastlyTimer, + }; use auctioneer_core::host_primitives::{ - HealthAuthz, HealthBootError, counter_scope_handle, health_authorize, health_policy_handle, + DelegateToDevStrategy, DevRng, SystemClock, TestClockHandle, + bootstrap_adapter_with_envelope, counter_scope_handle, }; + use auctioneer_runtime::FakeClock; + use edgezero_core::http::StatusCode; + use std::sync::Arc; + use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; - const FLAG_KEY: &str = "allow_unauthenticated_health"; const TOKEN: &str = "auctioneer-development-only-health-test-token"; - const FLAG_TOKEN: &str = "flagHeadF8v3.flagBodyJ7m2.flagSigQ5n9"; - #[test] - #[ignore = "requires the local manifest and unset token env supplied by tests/manifest_health_stores.rs"] - fn unset_local_token_resolves_closed() { - let policy = - resolve_health_policy().expect("the declared local aliases open without a token"); - assert!(matches!(policy, HealthAccessPolicy::Closed)); + fn verify_health(expected: StatusCode, bearer: Option<&str>) { + super::install_boot_log::(); + let profile = super::envelope::resource_profile(); + bootstrap_adapter_with_envelope( + profile.boot_envelope(), + || Arc::new(SystemClock::new()), + || Arc::new(DevRng::new()), + || Arc::new(FastlyTimer::new(ImmediateDelaySource)), + || Arc::new(FastlyDecodeCooperator), + || Arc::new(DelegateToDevStrategy), + ) + .expect("production envelope boots"); + super::install_health().expect("scope needs no provider policy read"); + assert_eq!(counter_scope_handle(), "request"); + let generated = + OffsetDateTime::parse(auctioneer_config_data::gvl_archive_generated_at(), &Rfc3339) + .unwrap(); + let expires = + OffsetDateTime::from_unix_timestamp(auctioneer_config_data::gcl_expires_at_unix_secs()) + .unwrap(); + let now = generated + .saturating_add(Duration::days(1)) + .min(expires.saturating_sub(Duration::hours(1))); + let result = edgezero_adapter_fastly::run_app_with_hooks::( + |request, extensions| { + request.set_url("https://auctioneer.example.test/_auctioneer/health/gvl"); + request.set_method("GET"); + if let Some(presented) = bearer { + request.set_header("authorization", format!("Bearer {presented}")); + } + extensions.insert(TestClockHandle(Arc::new(FakeClock::new(now)))); + }, + |response| { + assert_eq!(response.status(), expected); + if expected == StatusCode::UNAUTHORIZED { + assert_eq!(response.headers()["www-authenticate"], "Bearer"); + } else { + let payload: serde_json::Value = serde_json::from_slice( + response.body().as_bytes().expect("buffered health payload"), + ) + .unwrap(); + assert_eq!(payload["counter_scope"], "request"); + assert_eq!(payload["status"], "ok"); + } + true + }, + ) + .expect("SDK health dispatch and terminal delivery complete"); + assert_eq!(result, Some(true), "the SDK finalize hook must execute"); } #[test] - #[ignore = "requires the local manifest and development token env supplied by tests/manifest_health_stores.rs"] - fn local_development_token_resolves_authenticated() { - let policy = resolve_health_policy().expect("the local health token resolves"); - let HealthAccessPolicy::Token(auth) = policy else { - panic!("the local development token must select bearer authentication"); - }; - assert!(auth.verify("auctioneer-development-only-health-test-token")); - assert!(!auth.verify("wrong-development-token")); + #[ignore = "requires token policy and SDK-backed stores supplied by manifest_health_stores"] + fn runtime_token_policy_uses_sdk_bindings() { + verify_health(StatusCode::OK, Some(TOKEN)); } #[test] - #[ignore = "requires malformed flag fixtures supplied by tests/manifest_health_stores.rs"] - fn misplaced_health_flag_is_diagnosed_and_installs_closed() { - super::install_boot_log::(); - assert!(health_policy_handle().is_none()); - let Err(error) = resolve_health_policy() else { - panic!("a credential in the health flag must be rejected"); - }; - assert!(matches!( - error.downcast_ref::(), - Some(HealthBootError::NonBooleanFlag { key }) if *key == FLAG_KEY - )); - for rendered in [error.to_string(), format!("{error:?}")] { - assert!(rendered.contains(FLAG_KEY)); - assert!(rendered.contains("not a strict boolean")); - for sensitive in [ - TOKEN, - "auctioneer-development", - "health-test-token", - FLAG_TOKEN, - "flagHeadF8v3", - "flagBodyJ7m2", - "flagSigQ5n9", - "F8v3", - "J7m2", - "Q5n9", - ] { - assert!( - !rendered.contains(sensitive), - "neither Display nor Debug may expose credentials or their segments" - ); - } - } + #[ignore = "requires explicit anonymous policy supplied by manifest_health_stores"] + fn runtime_anonymous_policy_is_explicit() { + verify_health(StatusCode::OK, None); + } - super::install_health().expect("a malformed flag only closes health"); - assert_eq!(counter_scope_handle(), "request"); - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Closed) - )); - for bearer in [None, Some(TOKEN), Some(FLAG_TOKEN)] { - assert_eq!(health_authorize(bearer), HealthAuthz::Deny); - } + #[test] + #[ignore = "requires token policy supplied by manifest_health_stores"] + fn runtime_token_policy_denies_wrong_bearer() { + verify_health(StatusCode::UNAUTHORIZED, Some("wrong-development-token")); } #[test] - #[ignore = "requires conflicting legacy store selectors supplied by tests/manifest_health_stores.rs"] - fn health_and_sdk_reads_use_logical_aliases_despite_legacy_selectors() { - use edgezero_adapter_fastly::config_store::FastlyConfigStore; - use edgezero_adapter_fastly::secret_store::FastlySecretStore; - use edgezero_core::config_store::ConfigStore as _; - use edgezero_core::secret_store::SecretStore as _; - use futures::executor::block_on; - - let stores = ::stores(); - let config_id = stores.config.expect("config alias").default; - let secrets_id = stores.secrets.expect("secret alias").default; - let sdk_config = FastlyConfigStore::try_open(config_id).expect("SDK opens config alias"); - assert_eq!( - block_on(sdk_config.get(FLAG_KEY)) - .expect("SDK reads config alias") - .as_deref(), - Some("false") - ); - let sdk_token = block_on(FastlySecretStore.get_bytes(secrets_id, "health_gvl_token")) - .expect("SDK reads secret alias") - .expect("logical alias token"); - assert!( - sdk_token.as_ref() == TOKEN.as_bytes(), - "SDK must read the logical token" - ); - let HealthAccessPolicy::Token(auth) = - resolve_health_policy().expect("health opens aliases") - else { - panic!( - "logical stores select bearer authentication, not the physical store's open policy" - ); - }; - assert!(auth.verify(TOKEN)); - assert!(!auth.verify("wrong-physical-store-token")); - super::run().expect("legacy selectors cannot break SDK response dispatch"); - assert_eq!(health_authorize(Some(TOKEN)), HealthAuthz::Allow); - assert_eq!(health_authorize(None), HealthAuthz::Deny); + #[ignore = "requires token policy supplied by manifest_health_stores"] + fn runtime_token_policy_denies_absent_bearer() { + verify_health(StatusCode::UNAUTHORIZED, None); } #[test] - #[ignore = "requires missing store fixtures supplied by tests/manifest_health_stores.rs"] - fn a_missing_health_store_stays_closed_through_sdk_dispatch() { - assert!( - resolve_health_policy().is_err(), - "the fixture must actually fail store resolution" - ); - super::run().expect("a store fault must not stop the SDK lifecycle"); - assert_eq!(counter_scope_handle(), "request"); - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Closed) - )); - for bearer in [None, Some(TOKEN)] { - assert_eq!(health_authorize(bearer), HealthAuthz::Deny); - } + #[ignore = "requires absent/invalid config or secret fixtures supplied by manifest_health_stores"] + fn runtime_faults_close_only_health() { + verify_health(StatusCode::UNAUTHORIZED, Some(TOKEN)); } } diff --git a/crates/auctioneer-adapter-fastly/tests/boot_logging.rs b/crates/auctioneer-adapter-fastly/tests/boot_logging.rs index d10ada5..fa72a15 100644 --- a/crates/auctioneer-adapter-fastly/tests/boot_logging.rs +++ b/crates/auctioneer-adapter-fastly/tests/boot_logging.rs @@ -135,7 +135,7 @@ exit 71 logs.contains("boot envelope over ceiling: requires"), "{logs}" ); - assert!(logs.contains("health policy unresolved"), "{logs}"); + assert!(!logs.contains("health policy unresolved"), "{logs}"); } } @@ -182,13 +182,8 @@ exit 71 "{logs}" ); } else { - // The health stores are absent. OFF must suppress their ERROR - // before the SDK receives the request lifecycle. - assert_eq!( - logs.contains("health policy unresolved"), - configured >= LevelFilter::Error, - "health setup respects {configured}: {logs}" - ); + // Missing stores cannot trigger a policy read during boot. + assert!(!logs.contains("health policy unresolved"), "{logs}"); assert_eq!( sink_logs .matches("boot envelope over ceiling: requires") diff --git a/crates/auctioneer-adapter-fastly/tests/boot_logging_and_stores.rs b/crates/auctioneer-adapter-fastly/tests/boot_logging_and_stores.rs index a712524..65a5aac 100644 --- a/crates/auctioneer-adapter-fastly/tests/boot_logging_and_stores.rs +++ b/crates/auctioneer-adapter-fastly/tests/boot_logging_and_stores.rs @@ -72,34 +72,19 @@ mod tests { } #[test] - fn the_health_bootstrap_opens_the_same_logical_aliases_as_sdk_registries() { - let source = source(); - assert!(source.contains("let stores = ::stores();")); - for direct in [ - "ConfigStore::try_open(config_id)?", - "SecretStore::open(secrets_id)?", + fn health_boot_installs_only_a_counter_scope_and_sdk_dispatch_owns_stores() { + let production = source().split("mod health_setup_tests").next().unwrap(); + assert!(production.contains("install_health_counter_scope(\"request\")")); + for removed in [ + "resolve_health_policy", + "ConfigStore::", + "SecretStore::", + "read_bool_flag", ] { assert!( - source.contains(direct), - "health and SDK registries must open baked resource-link aliases: {direct}" + !production.contains(removed), + "boot must not resolve health policy: {removed}" ); } } - - #[test] - fn a_store_fault_leaves_health_closed_rather_than_stopping_the_router() { - let source = source(); - let install_body = source - .split("fn install_health(") - .nth(1) - .and_then(|rest| rest.split("fn resolve_health_policy(").next()) - .expect("health installation precedes fallible policy resolution"); - assert!(install_body.contains("match resolve_health_policy()")); - assert!(install_body.contains("install_health_primitives(HealthAccessPolicy::Closed")); - assert!(install_body.contains("health policy unresolved")); - assert!(!install_body.contains("ConfigStore::try_open")); - assert!(!install_body.contains("SecretStore::open")); - assert!(!source.contains("ConfigStore::open(")); - assert!(!source.contains(".plaintext()")); - } } diff --git a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs index 80fad0e..0a4fb0e 100644 --- a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs +++ b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs @@ -1,20 +1,13 @@ -//! Local health provisioning, including optional real SDK reads in Viceroy. +//! Local provisioning and real shared health requests through SDK-backed guest stores. #[cfg(test)] mod tests { use auctioneer_core::App; - use auctioneer_core::host_primitives::{ - BearerToken, HealthAccessPolicy, HealthBootError, decide_health_policy, read_bool_flag, - }; - use edgezero_core::app::Hooks; - #[cfg(not(target_arch = "wasm32"))] - use std::process::Output; + use edgezero_core::{app::Hooks, blob_envelope::BlobEnvelope}; + use serde_json::json; use toml::{Value, from_str}; - const FLAG_KEY: &str = "allow_unauthenticated_health"; const TOKEN: &str = "auctioneer-development-only-health-test-token"; - const FLAG_TOKEN: &str = "flagHeadF8v3.flagBodyJ7m2.flagSigQ5n9"; - const PADDED_FLAG_TOKEN: &str = " \tflagHeadF8v3.flagBodyJ7m2.flagSigQ5n9\n"; #[cfg(all(unix, not(target_arch = "wasm32")))] #[test] @@ -44,411 +37,172 @@ exit 71 ) .unwrap(); fs::set_permissions(&probe, fs::Permissions::from_mode(0o755)).unwrap(); - let mut results = Vec::new(); - for test in [ - "tests::the_local_manifest_resolves_health_with_and_without_a_development_token", - "tests::logical_store_aliases_and_fail_closed_faults_execute_in_the_guest", - ] { - let output = Command::new(env::current_exe().unwrap()) - .args(["--exact", test, "--ignored", "--nocapture"]) - .env_clear() - .env("PATH", &directory) - .env( - "AUCTIONEER__TEST__FASTLY_LOGGING_WASM", - "synthetic-unused-guest.wasm", - ) - .env( - "AUCTIONEER__USERSYNC__COOKIE_KEYRING", - "synthetic-keyring-sentinel", - ) - .env("AUCTIONEER__TEST__FIXTURE_ENV_CONTROL", "synthetic-control") - .output() - .unwrap(); - results.push((test, output)); - } + let test = "tests::runtime_health_policy_and_provider_faults_execute_in_the_guest"; + let output = Command::new(env::current_exe().unwrap()) + .args(["--exact", test, "--ignored", "--nocapture"]) + .env_clear() + .env("PATH", &directory) + .env( + "AUCTIONEER__TEST__FASTLY_LOGGING_WASM", + "synthetic-unused-guest.wasm", + ) + .env( + "AUCTIONEER__USERSYNC__COOKIE_KEYRING", + "synthetic-keyring-sentinel", + ) + .env("AUCTIONEER__TEST__FIXTURE_ENV_CONTROL", "synthetic-control") + .output() + .unwrap(); fs::remove_dir_all(directory).unwrap(); - for (test, output) in results { - let logs = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(!output.status.success(), "the probe must stop {test}"); - assert!( - logs.contains("fixture-child-env: keyring absent; control retained"), - "{test}: the health child must remove inherited signing keys: {logs}" - ); - assert!( - !logs.contains("fixture-child-env: keyring inherited"), - "{logs}" - ); - } - } - - fn assert_no_health_credentials(rendered: &str) { - for sensitive in [ - TOKEN, - "auctioneer-development", - "health-test-token", - FLAG_TOKEN, - "flagHeadF8v3", - "flagBodyJ7m2", - "flagSigQ5n9", - "F8v3", - "J7m2", - "Q5n9", - ] { - assert!( - !rendered.contains(sensitive), - "health credentials and their segments must not be exposed" - ); - } - } - - #[test] - fn rejected_health_flags_preserve_key_and_type_without_raw_values() { - for raw_flag in [ - FLAG_TOKEN, - PADDED_FLAG_TOKEN, - TOKEN, - "not-a-boolean", - "TRUE", - "", - ] { - let error = read_bool_flag(FLAG_KEY, Some(raw_flag)) - .expect_err("a non-boolean flag must be rejected"); - assert!(matches!( - &error, - HealthBootError::NonBooleanFlag { key } if *key == FLAG_KEY - )); - let display = error.to_string(); - let debug = format!("{error:?}"); - assert!(display.contains("not a strict boolean")); - assert!(debug.contains("NonBooleanFlag")); - for rendered in [display, debug] { - assert!(rendered.contains(FLAG_KEY)); - assert_no_health_credentials(&rendered); - if !raw_flag.trim().is_empty() { - assert!( - !rendered.contains(raw_flag.trim()), - "neither Display nor Debug may expose the rejected flag" - ); - } - } - } + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output.status.success(), "the probe must stop {test}"); + assert!( + logs.contains("fixture-child-env: keyring absent; control retained"), + "{test}: the health child must remove inherited signing keys: {logs}" + ); + assert!( + !logs.contains("fixture-child-env: keyring inherited"), + "{logs}" + ); } fn manifest() -> Value { - from_str(include_str!("../../../fastly.toml")).expect("the local Fastly manifest parses") - } - - fn local_policy(token: Option<&str>) -> HealthAccessPolicy { - let manifest = manifest(); - let config_id = ::stores() - .config - .expect("config store") - .default; - let raw_flag = manifest["local_server"]["config_stores"][config_id]["contents"] - ["allow_unauthenticated_health"] - .as_str(); - let allow = read_bool_flag("allow_unauthenticated_health", raw_flag) - .expect("the local health flag is valid"); - decide_health_policy(token.and_then(BearerToken::new), allow) - .expect("local health inputs resolve without fallback") + from_str(include_str!("../../../fastly.toml")).expect("local Fastly manifest parses") } #[test] - fn an_absent_or_empty_local_token_keeps_health_closed() { - for token in [None, Some("")] { - assert!(matches!(local_policy(token), HealthAccessPolicy::Closed)); - } - } - - #[test] - fn a_development_only_local_token_requires_the_matching_bearer() { - let HealthAccessPolicy::Token(auth) = - local_policy(Some("auctioneer-development-only-health-test-token")) - else { - panic!("a configured local token must select bearer authentication"); - }; - assert!(auth.verify("auctioneer-development-only-health-test-token")); - assert!(!auth.verify("wrong-development-token")); - assert!(!auth.verify("")); - } - - #[test] - fn the_local_health_flag_is_a_string_in_the_declared_config_store() { + fn local_config_is_empty_until_push_and_has_no_provider_health_authority() { let manifest = manifest(); - let config_id = ::stores() - .config - .expect("config store") - .default; + assert_eq!(manifest["service_id"].as_str(), Some("")); + let config_id = ::stores().config.unwrap().default; let config = &manifest["local_server"]["config_stores"][config_id]; assert_eq!(config["format"].as_str(), Some("inline-toml")); - let contents = config["contents"].as_table().expect("inline config values"); - let flag = contents - .get("allow_unauthenticated_health") - .and_then(Value::as_str); - assert_eq!(flag, Some("false")); - assert!(!read_bool_flag("allow_unauthenticated_health", flag).unwrap()); - assert_eq!( - contents.get(config_id).and_then(Value::as_str), - Some(""), - "local runtime policy must be supplied by config push rather than the shipped manifest" - ); - assert!( - !contents.contains_key("health_gvl_token"), - "bearer credentials do not belong in the Config Store" - ); + let contents = config["contents"].as_table().unwrap(); + assert_eq!(contents.get(config_id).and_then(Value::as_str), Some("")); + assert!(!contents.contains_key("allow_unauthenticated_health")); + assert!(!contents.contains_key("health_gvl_token")); } #[test] - fn the_local_health_secret_is_env_backed_in_the_declared_secret_store() { + fn local_health_secret_is_env_backed_in_the_declared_default_store() { let manifest = manifest(); - let secrets_id = ::stores() - .secrets - .expect("secret store") - .default; + let secrets_id = ::stores().secrets.unwrap().default; let entries = manifest["local_server"]["secret_stores"][secrets_id] .as_array() - .expect("Viceroy secret stores use arrays of key/value entries"); + .unwrap(); let token = entries .iter() .find(|entry| entry["key"].as_str() == Some("health_gvl_token")) - .expect("the health token key is materialized even when its env var is unset") + .expect("local health provisioning placeholder") .as_table() - .expect("a secret entry is a table"); + .unwrap(); assert_eq!( token.get("env").and_then(Value::as_str), Some("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN") ); - assert!( - !token.contains_key("data") && !token.contains_key("file"), - "Viceroy requires exactly one source; no operator token is checked in" - ); + assert!(!token.contains_key("data") && !token.contains_key("file")); } #[cfg(not(target_arch = "wasm32"))] - fn assert_guest_diagnostics( - test: &str, - case_index: usize, - raw_flag: Option<&str>, - output: &Output, - ) { - let logs = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(output.status.success(), "{test}: {logs}"); - assert!( - logs.contains("1 passed"), - "the SDK case must execute: {logs}" - ); - assert_no_health_credentials(&logs); - if let Some(flag) = raw_flag { - assert!( - !logs.contains(flag.trim()), - "the process logger must not expose the rejected flag" - ); - let diagnostics = logs - .lines() - .filter(|line| { - line.contains("health policy unresolved") - && (line.contains("[auctioneer]") || line.contains("auctioneer_log :: ")) - }) - .collect::>(); - assert_eq!( - diagnostics.len(), - 1, - "case {case_index}: the real installer must diagnose the flag: {logs}" - ); - for diagnostic in diagnostics { - assert!( - diagnostic.contains("[auctioneer] ERROR auctioneer::health:") - || (diagnostic.contains("auctioneer_log :: ") - && diagnostic.contains(" ERROR health policy unresolved")), - "the selected sink must carry the ERROR diagnostic: {logs}" - ); - assert!( - diagnostic.contains(FLAG_KEY), - "case {case_index}: the flag key must remain diagnosable: {logs}" - ); + fn runtime_fixture(case: &str) -> (Value, &'static str) { + let stores = ::stores(); + let config_id = stores.config.unwrap().default; + let secrets_id = stores.secrets.unwrap().default; + let mut fixture = manifest(); + let mut data = json!({"schema_version": 1_u8}); + if case != "omitted" { + data["gvl"] = json!({"authenticated_health": { + "enabled": case != "anonymous", + "token_secret": if case == "renamed-entry" { "renamed_health_token" } else { "health_gvl_token" }, + }}); + if case == "anonymous" { assert!( - diagnostic.contains("not a strict boolean"), - "case {case_index}: the wrong-type reason must remain diagnosable: {logs}" + data["gvl"]["authenticated_health"] + .as_object_mut() + .unwrap() + .remove("token_secret") + .is_some() ); } - } - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - #[ignore = "requires Viceroy and AUCTIONEER__TEST__FASTLY_LOGGING_WASM pointing to prebuilt WASM library tests"] - fn the_local_manifest_resolves_health_with_and_without_a_development_token() { - use std::env; - use std::fs::{remove_file, write}; - use std::path::Path; - use std::process::{Command, id}; - - let wasm = - env::var_os("AUCTIONEER__TEST__FASTLY_LOGGING_WASM").expect("WASM test artifact path"); - let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fastly.toml"); - for (case_index, (test, token, raw_flag)) in [ - ( - "health_setup_tests::unset_local_token_resolves_closed", - None, - None, - ), - ( - "health_setup_tests::local_development_token_resolves_authenticated", - Some(TOKEN), - None, - ), - ( - "health_setup_tests::misplaced_health_flag_is_diagnosed_and_installs_closed", - None, - Some(FLAG_TOKEN), - ), - ( - "health_setup_tests::misplaced_health_flag_is_diagnosed_and_installs_closed", - None, - Some(PADDED_FLAG_TOKEN), - ), - ( - "health_setup_tests::misplaced_health_flag_is_diagnosed_and_installs_closed", - Some(TOKEN), - Some(FLAG_TOKEN), - ), - ( - "health_setup_tests::misplaced_health_flag_is_diagnosed_and_installs_closed", - Some(TOKEN), - Some(PADDED_FLAG_TOKEN), - ), - ] - .into_iter() - .enumerate() - { - let fixture_path = raw_flag.map(|flag| { - let mut fixture = manifest(); - let config_id = ::stores() - .config - .expect("config store") - .default; - fixture["local_server"]["config_stores"][config_id]["contents"][FLAG_KEY] = - Value::String(flag.to_owned()); - let path = env::temp_dir().join(format!( - "auctioneer-fastly-health-{}-{case_index}.toml", - id() - )); - write( - &path, - toml::to_string(&fixture).expect("health fixture TOML"), - ) - .expect("write health fixture"); - path - }); - let mut command = Command::new("viceroy"); - command - .arg("run") - .arg("-C") - .arg(fixture_path.as_deref().unwrap_or(&manifest_path)) - .arg(&wasm) - .args(["--exact", test, "--ignored", "--nocapture"]) - .env_remove("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN") - .env_remove("AUCTIONEER__USERSYNC__COOKIE_KEYRING"); - if let Some(value) = token { - command.env("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN", value); - } - if cfg!(target_os = "macos") && env::var_os("SSL_CERT_FILE").is_none() { - command.env("SSL_CERT_FILE", "/etc/ssl/cert.pem"); + if case == "malformed" { + data["gvl"]["authenticated_health"]["enabled"] = json!("true"); } - let attempt = command.output(); - if let Some(path) = fixture_path { - remove_file(path).expect("remove health fixture"); - } - let output = attempt.expect("run Viceroy"); - assert_guest_diagnostics(test, case_index, raw_flag, &output); } + let blob = BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned()); + fixture["local_server"]["config_stores"][config_id]["contents"][config_id] = + Value::String(serde_json::to_string(&blob).unwrap()); + if case == "missing-config" { + assert!( + fixture["local_server"]["config_stores"] + .as_table_mut() + .unwrap() + .remove(config_id) + .is_some() + ); + } + if case == "missing-secret-store" { + assert!( + fixture["local_server"]["secret_stores"] + .as_table_mut() + .unwrap() + .remove(secrets_id) + .is_some() + ); + } + if case == "renamed-entry" { + let entries = fixture["local_server"]["secret_stores"][secrets_id] + .as_array_mut() + .unwrap(); + let token = entries + .iter_mut() + .find(|entry| entry["key"].as_str() == Some("health_gvl_token")) + .unwrap(); + token["key"] = Value::String("renamed_health_token".to_owned()); + } + let test = match case { + "token" | "renamed-entry" => { + "health_setup_tests::runtime_token_policy_uses_sdk_bindings" + } + "anonymous" => "health_setup_tests::runtime_anonymous_policy_is_explicit", + "wrong-bearer" => "health_setup_tests::runtime_token_policy_denies_wrong_bearer", + "absent-bearer" => "health_setup_tests::runtime_token_policy_denies_absent_bearer", + _ => "health_setup_tests::runtime_faults_close_only_health", + }; + (fixture, test) } #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore = "requires Viceroy and AUCTIONEER__TEST__FASTLY_LOGGING_WASM pointing to prebuilt WASM library tests"] - fn logical_store_aliases_and_fail_closed_faults_execute_in_the_guest() { - use std::env; - use std::fs::{remove_file, write}; - use std::process::{Command, id}; - use toml::map::Map; - - let wasm = - env::var_os("AUCTIONEER__TEST__FASTLY_LOGGING_WASM").expect("WASM test artifact path"); - let stores = ::stores(); - let config_id = stores.config.expect("config alias").default; - let secrets_id = stores.secrets.expect("secret alias").default; - for case in ["legacy-selectors", "missing-config", "missing-secrets"] { - let mut fixture = manifest(); - let local = fixture["local_server"] - .as_table_mut() - .expect("local manifest"); - let test = if case == "legacy-selectors" { - let mut legacy = Map::new(); - for (kind, logical, physical) in [ - ("CONFIG", config_id, "auctioneer_runtime_physical"), - ("SECRETS", secrets_id, "auctioneer_secrets_physical"), - ] { - for prefix in ["EDGEZERO", "EDGEZERO__SERVICES__0000000000000000000000"] { - legacy.insert( - format!("{prefix}__STORES__{kind}__{}__NAME", logical.to_uppercase()), - Value::String(physical.to_owned()), - ); - } - } - let config = local["config_stores"] - .as_table_mut() - .expect("config stores"); - let mut physical = config[config_id].clone(); - physical["contents"][FLAG_KEY] = Value::String("true".to_owned()); - config.insert("auctioneer_runtime_physical".to_owned(), physical); - config.insert( - "edgezero_runtime_env".to_owned(), - Value::Table(Map::from_iter([ - ("format".to_owned(), Value::String("inline-toml".to_owned())), - ("contents".to_owned(), Value::Table(legacy)), - ])), - ); - local["secret_stores"] - .as_table_mut() - .expect("secret stores") - .insert( - "auctioneer_secrets_physical".to_owned(), - Value::Array(vec![Value::Table(Map::from_iter([ - ( - "key".to_owned(), - Value::String("health_gvl_token".to_owned()), - ), - ( - "data".to_owned(), - Value::String("wrong-physical-store-token".to_owned()), - ), - ]))]), - ); - "health_setup_tests::health_and_sdk_reads_use_logical_aliases_despite_legacy_selectors" - } else { - let (kind, alias) = if case == "missing-config" { - ("config_stores", config_id) - } else { - ("secret_stores", secrets_id) - }; - local[kind] - .as_table_mut() - .expect("store table") - .remove(alias); - "health_setup_tests::a_missing_health_store_stays_closed_through_sdk_dispatch" - }; - let path = - env::temp_dir().join(format!("auctioneer-fastly-alias-{}-{case}.toml", id())); - write(&path, toml::to_string(&fixture).expect("fixture TOML")).expect("write fixture"); + fn runtime_health_policy_and_provider_faults_execute_in_the_guest() { + use std::{ + env, fs, + process::{Command, id}, + }; + let wasm = env::var_os("AUCTIONEER__TEST__FASTLY_LOGGING_WASM").expect("WASM test path"); + for case in [ + "token", + "wrong-bearer", + "absent-bearer", + "renamed-entry", + "anonymous", + "omitted", + "malformed", + "missing-config", + "missing-secret-store", + "empty-secret", + "oversized-secret", + ] { + let (fixture, test) = runtime_fixture(case); + let path = env::temp_dir().join(format!( + "auctioneer-fastly-runtime-health-{}-{case}.toml", + id() + )); + fs::write(&path, toml::to_string(&fixture).unwrap()).unwrap(); let mut command = Command::new("viceroy"); command .arg("run") @@ -456,25 +210,31 @@ exit 71 .arg(&path) .arg(&wasm) .args(["--exact", test, "--ignored", "--nocapture"]) - .env("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN", TOKEN) + .env_remove("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN") .env_remove("AUCTIONEER__USERSYNC__COOKIE_KEYRING"); + let token = match case { + "empty-secret" => String::new(), + "oversized-secret" => "x".repeat(4097), + _ => TOKEN.to_owned(), + }; + command.env("AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN", token); if cfg!(target_os = "macos") && env::var_os("SSL_CERT_FILE").is_none() { command.env("SSL_CERT_FILE", "/etc/ssl/cert.pem"); } let attempt = command.output(); - remove_file(path).expect("remove fixture"); + fs::remove_file(path).unwrap(); let output = attempt.expect("run Viceroy"); - assert_guest_diagnostics(test, 0, None, &output); let logs = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!(!logs.contains("wrong-physical-store-token")); - assert_eq!( - logs.contains("health policy unresolved"), - case != "legacy-selectors", - "{case}: {logs}" + assert!(output.status.success(), "{case}: {logs}"); + assert!(logs.contains("1 passed"), "guest case must execute: {logs}"); + assert!(!logs.contains(TOKEN), "credentials must not appear in logs"); + assert!( + !logs.contains("health policy unresolved"), + "boot has no provider health policy: {logs}" ); } } diff --git a/crates/auctioneer-adapter-spin/spin.toml b/crates/auctioneer-adapter-spin/spin.toml index 9921840..6c69f17 100644 --- a/crates/auctioneer-adapter-spin/spin.toml +++ b/crates/auctioneer-adapter-spin/spin.toml @@ -4,20 +4,13 @@ spin_manifest_version = 2 name = "auctioneer-adapter-spin" version = "0.1.0" -# Spin resolves a component variable only when the APPLICATION -# declares it and the component maps it. An undeclared variable cannot -# be set by `spin up --variable` or by a runtime config provider, so -# leaving these out made authenticated health checks unconfigurable: -# `variables::get` always errored, the adapter read both as absent, and -# the decision table fail-closed to 401 with no way to supply a token. -# -# `allow_unauthenticated_health` defaults to "false" so an operator who -# sets nothing gets the fail-closed policy rather than a boot error. -# Optional Spin variables require a default. Empty secret defaults are -# unusable: health stays closed and UID processing refuses without keys. -# Operators provision real credentials through a variable provider. +# SDK secret reads need both the application declaration and component mapping. +# Select health_gvl_token via gvl.authenticated_health.token_secret in the pushed +# operator policy. Different entry names need their own secret declaration and +# component mapping; the SDK lowercases names before variables::get. +# Empty secret defaults are unusable and keep health closed. Provision real +# credentials through a variable provider. [variables] -allow_unauthenticated_health = { default = "false" } health_gvl_token = { required = false, default = "", secret = true } # One atomic JSON keyring, resolved only when user-sync needs UID state. # Provision through a variable provider; never bake a usable signing key. @@ -36,7 +29,6 @@ allowed_outbound_hosts = ["https://*:*"] # resolve them. Declaring without mapping leaves the reads failing # exactly as if nothing were declared. [component.auctioneer-adapter-spin.variables] -allow_unauthenticated_health = "{{ allow_unauthenticated_health }}" health_gvl_token = "{{ health_gvl_token }}" usersync_cookie_keyring = "{{ usersync_cookie_keyring }}" edgezero_logging_level = "{{ edgezero_logging_level }}" diff --git a/crates/auctioneer-adapter-spin/src/health.rs b/crates/auctioneer-adapter-spin/src/health.rs index 6ea0838..8dcb8be 100644 --- a/crates/auctioneer-adapter-spin/src/health.rs +++ b/crates/auctioneer-adapter-spin/src/health.rs @@ -1,33 +1,9 @@ -//! Health-only degradation shared by production requests and host regressions. +//! Adapters own only the immutable health counter scope. -use auctioneer_core::host_primitives::{ - BearerToken, HealthAccessPolicy, HealthBootError, decide_health_policy, - install_health_primitives, read_bool_flag, -}; +use auctioneer_core::host_primitives::{HealthBootError, install_health_counter_scope}; -pub(super) const FLAG_VARIABLE: &str = "allow_unauthenticated_health"; - -/// Resolution faults close health without stopping routing. Installation -/// conflicts remain fatal because an instance's frozen policy cannot change. -pub(super) fn install( - raw_flag: Option<&str>, - raw_token: Option, -) -> Result<(), HealthBootError> { - let resolved = read_bool_flag(FLAG_VARIABLE, raw_flag).and_then(|allow_unauth| { - decide_health_policy(raw_token.and_then(BearerToken::new), allow_unauth) - }); - let policy = match resolved { - Ok(policy) => policy, - Err(err) => { - log::error!( - target: "auctioneer::health", - "health policy unresolved ({err}); serving with health CLOSED so \ - /status and auctions remain available" - ); - HealthAccessPolicy::Closed - } - }; - install_health_primitives(policy, "instance") +pub(super) fn install() -> Result<(), HealthBootError> { + install_health_counter_scope("instance") } #[cfg(test)] @@ -36,13 +12,14 @@ mod tests { use crate::envelope; use auctioneer_core::App as AuctioneerApp; use auctioneer_core::host_primitives::{ - DelegateToDevStrategy, DevRng, HealthAccessPolicy, HealthAuthz, HealthBootError, - SystemClock, bootstrap_adapter_with_envelope, counter_scope_handle, health_authorize, - health_policy_handle, install_health_primitives, install_stderr_boot_logger_with_level, + DelegateToDevStrategy, DevRng, HealthBootError, SystemClock, TestClockHandle, + bootstrap_adapter_with_envelope, counter_scope_handle, install_health_counter_scope, + install_stderr_boot_logger_with_level, }; - use auctioneer_runtime::{ImmediateResolveTimer, NoopDecodeCooperator}; + use auctioneer_runtime::{FakeClock, ImmediateResolveTimer, NoopDecodeCooperator}; use edgezero_adapter_spin::SPIN_PLATFORM; use edgezero_core::app::App; + use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, ConfigValue, @@ -66,75 +43,26 @@ mod tests { use std::process::Command; use std::rc::Rc; use std::sync::Arc; + use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; const CHILD: &str = "AUCTIONEER__TEST__SPIN_HEALTH_CHILD"; const TOKEN: &str = "health-token-must-not-appear-in-logs"; - const FLAG_TOKEN: &str = "flagHeadF8v3.flagBodyJ7m2.flagSigQ5n9"; - const PADDED_FLAG_TOKEN: &str = " \tflagHeadF8v3.flagBodyJ7m2.flagSigQ5n9\n"; - - fn assert_no_health_credentials(rendered: &str) { - for sensitive in [ - TOKEN, - "health-token", - "must-not-appear-in-logs", - FLAG_TOKEN, - "flagHeadF8v3", - "flagBodyJ7m2", - "flagSigQ5n9", - "F8v3", - "J7m2", - "Q5n9", - ] { - assert!( - !rendered.contains(sensitive), - "health credentials and their segments must not be exposed" - ); - } - } - #[test] - fn rejected_health_flags_preserve_key_and_type_without_raw_values() { - for raw_flag in [ - FLAG_TOKEN, - PADDED_FLAG_TOKEN, - TOKEN, - "not-a-boolean", - "TRUE", - "", - ] { - let error = super::read_bool_flag(super::FLAG_VARIABLE, Some(raw_flag)) - .expect_err("a non-boolean flag must be rejected"); - assert!(matches!( - &error, - HealthBootError::NonBooleanFlag { key } if *key == super::FLAG_VARIABLE - )); - let display = error.to_string(); - let debug = format!("{error:?}"); - assert!(display.contains("not a strict boolean")); - assert!(debug.contains("NonBooleanFlag")); - for rendered in [display, debug] { - assert!(rendered.contains(super::FLAG_VARIABLE)); - assert_no_health_credentials(&rendered); - if !raw_flag.trim().is_empty() { - assert!( - !rendered.contains(raw_flag.trim()), - "neither Display nor Debug may expose the rejected flag" - ); - } + fn config_envelope(case: &str) -> String { + let mut data = json!({"schema_version": 1_u8}); + match case { + "anonymous" => data["gvl"] = json!({"authenticated_health": {"enabled": false}}), + "missing-secret" => { + data["gvl"] = json!({"authenticated_health": { + "enabled": true, "token_secret": "health_gvl_token" + }}); + } + "malformed-config" => { + data["gvl"] = json!({"authenticated_health": {"enabled": "true"}}); } + _ => {} } - } - - // A valid envelope over the minimal versioned policy keeps application - // configuration independent of the health inputs being exercised. - fn config_envelope() -> String { - json!({ - "data": {"schema_version": 1_u8}, - "sha256": "a9d5f6d002d956b8af5787a05e0ca000d45c03977ffa54ee8fbed719fed5fd23", - "version": 1_u32, - "generated_at": "1970-01-01T00:00:00Z", - }) - .to_string() + serde_json::to_string(&BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned())).unwrap() } // SDK request conversion supplies this extension before app dispatch. @@ -175,13 +103,13 @@ mod tests { } } - struct HealthyConfig; + struct HealthyConfig(&'static str); #[async_trait::async_trait(?Send)] impl ConfigStore for HealthyConfig { async fn get(&self, key: &str) -> Result, ConfigStoreError> { assert_eq!(key, "auctioneer_runtime"); - Ok(Some(config_envelope().into())) + Ok(Some(config_envelope(self.0).into())) } async fn get_bounded( @@ -194,7 +122,7 @@ mod tests { ) -> Result, ConfigStoreError> { assert_eq!(key, "auctioneer_runtime"); finish_bounded_config_read( - Ok(Some(config_envelope())), + Ok(Some(config_envelope(self.0))), clock, deadline, max_backend_bytes, @@ -203,15 +131,33 @@ mod tests { } } - fn dispatch(app: &App, mut request: Request) -> Box { + fn dispatch( + app: &App, + mut request: Request, + case: &'static str, + ) -> Box { let config: ConfigRegistry = StoreRegistry::single_id( "auctioneer_runtime".to_owned(), ConfigStoreBinding { default_key: "auctioneer_runtime".to_owned(), - handle: ConfigStoreHandle::new(Arc::new(HealthyConfig)), + handle: ConfigStoreHandle::new(Arc::new(HealthyConfig(case))), }, ); - request.extensions_mut().insert(config); + if case != "missing-config" { + request.extensions_mut().insert(config); + } + let generated = + OffsetDateTime::parse(auctioneer_config_data::gvl_archive_generated_at(), &Rfc3339) + .unwrap(); + let expires = + OffsetDateTime::from_unix_timestamp(auctioneer_config_data::gcl_expires_at_unix_secs()) + .unwrap(); + let now = generated + .saturating_add(Duration::days(1)) + .min(expires.saturating_sub(Duration::hours(1))); + request + .extensions_mut() + .insert(TestClockHandle(Arc::new(FakeClock::new(now)))); match block_on(app.dispatch_ingress( request, MonotonicInstant::now(), @@ -228,7 +174,7 @@ mod tests { } } - fn assert_closed_routes_still_dispatch() { + fn assert_routes_still_dispatch(case: &'static str) { let limits = envelope::limits(); let target = envelope::target(); bootstrap_adapter_with_envelope( @@ -246,16 +192,25 @@ mod tests { .uri("/status") .body(Body::empty()) .unwrap(); - let mut status = dispatch(&app, status_request); + let mut status = dispatch(&app, status_request, case); assert!(matches!( status.response_mut().status(), StatusCode::OK | StatusCode::NO_CONTENT )); assert_eq!( status.response_mut().headers()["x-auctioneer-config-status"], - "ok", - "a health-only fault must not change the application config verdict" + if matches!(case, "missing-config" | "malformed-config") { + "degraded" + } else { + "ok" + } ); + if matches!(case, "missing-config" | "malformed-config") { + assert_eq!( + status.response_mut().body().as_bytes().unwrap(), + b"config: degraded (malformed_config)\n" + ); + } drop(status); let mut auction_request = request_builder() @@ -271,7 +226,7 @@ mod tests { auction_request .extensions_mut() .insert(HttpClient::with_client(ValidationOnlyClient)); - let mut auction = dispatch(&app, auction_request); + let mut auction = dispatch(&app, auction_request, case); let response = auction.response_mut(); let body = response .body() @@ -279,10 +234,17 @@ mod tests { .expect("auction validation returns a buffered error body"); assert_eq!( response.status(), - StatusCode::BAD_REQUEST, - "the auction reaches its own validation rather than failing health boot; body: {}", + if matches!(case, "missing-config" | "malformed-config") { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::BAD_REQUEST + }, + "the auction reaches its own config or validation; body: {}", String::from_utf8_lossy(body) ); + if matches!(case, "missing-config" | "malformed-config") { + assert_eq!(body, b"Malformed host configuration"); + } drop(auction); for bearer in [None, Some("Bearer health-token-must-not-appear-in-logs")] { @@ -290,155 +252,68 @@ mod tests { if let Some(value) = bearer { health_builder = health_builder.header("authorization", value); } - let mut health = dispatch(&app, health_builder.body(Body::empty()).unwrap()); - assert_eq!(health.response_mut().status(), StatusCode::UNAUTHORIZED); - assert_eq!( - health.response_mut().headers()["www-authenticate"], - "Bearer" - ); - } - } - - fn scenario_inputs(case: &str) -> (Option<&'static str>, Option<&'static str>) { - match case { - "malformed" => (Some("not-a-boolean"), None), - "malformed-with-token" => (Some("TRUE"), Some(TOKEN)), - "token-in-flag" => (Some(FLAG_TOKEN), None), - "padded-token-in-flag" => (Some(PADDED_FLAG_TOKEN), None), - "token-in-flag-with-token" => (Some(FLAG_TOKEN), Some(TOKEN)), - "blank" => (Some(""), None), - "conflicting" => (Some("true"), Some(TOKEN)), - "absent" => (None, None), - "default" => (Some("false"), None), - "empty-token" => (Some("false"), Some("")), - "open" | "installed-open" => (Some(" true "), None), - "token" | "installed-token" => (Some("false"), Some(TOKEN)), - _ => panic!("unknown health scenario"), - } - } - - fn assert_scope_conflict_preserves_policy() { - install_health_primitives(HealthAccessPolicy::Closed, "other").unwrap(); - assert!(matches!( - super::install(Some("invalid"), None), - Err(HealthBootError::ConflictingPolicy) - )); - assert_eq!(counter_scope_handle(), "other"); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - } - - fn assert_installed_policy_preserved(case: &str) { - assert!(matches!( - super::install(Some("invalid"), None), - Err(HealthBootError::ConflictingPolicy) - )); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Allow)); - assert_eq!( - health_authorize(None), - if case == "installed-open" { - HealthAuthz::Allow + let mut health = dispatch(&app, health_builder.body(Body::empty()).unwrap(), case); + if case == "anonymous" { + assert_eq!(health.response_mut().status(), StatusCode::OK); } else { - HealthAuthz::Deny - }, - "a failed fallback preserves the previously installed policy" - ); - } - - fn assert_closed_policy_cannot_be_replaced() { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Closed) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - assert_closed_routes_still_dispatch(); - for (replacement_flag, replacement_token) in - [(Some("true"), None), (Some("false"), Some(TOKEN))] - { - assert!(matches!( - super::install(replacement_flag, replacement_token.map(str::to_owned)), - Err(HealthBootError::ConflictingPolicy) - )); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Deny)); - } - } - - fn assert_resolved_policy(case: &str) { - match case { - "open" => { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::UnauthenticatedAcknowledged) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Allow)); - } - "token" => { - assert!(matches!( - health_policy_handle(), - Some(HealthAccessPolicy::Token(_)) - )); - assert!(matches!(health_authorize(None), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some("wrong")), HealthAuthz::Deny)); - assert!(matches!(health_authorize(Some(TOKEN)), HealthAuthz::Allow)); + assert_eq!(health.response_mut().status(), StatusCode::UNAUTHORIZED); + assert_eq!( + health.response_mut().headers()["www-authenticate"], + "Bearer" + ); } - _ => assert_closed_policy_cannot_be_replaced(), } } - // The policy and counter scope are process-global OnceLocks. Each - // scenario needs its own process, including repeated installs in one instance. + // The counter scope is process-global; each independent case needs a child. #[test] - fn health_policy_child() { + fn health_scope_child() { let Ok(case) = env::var(CHILD) else { return; }; assert!(install_stderr_boot_logger_with_level(LevelFilter::Info)); - assert!(health_policy_handle().is_none()); - if case == "installed-scope" { - assert_scope_conflict_preserves_policy(); + if case == "scope-conflict" { + install_health_counter_scope("other").unwrap(); + assert!(matches!( + super::install(), + Err(HealthBootError::ConflictingScope) + )); + assert_eq!(counter_scope_handle(), "other"); return; } - let (flag, token) = scenario_inputs(&case); - super::install(flag, token.map(str::to_owned)) - .expect("bad health input degrades, but does not fail request boot"); - super::install(flag, token.map(str::to_owned)).expect("repeat installs are idempotent"); + super::install().expect("counter scope has no config or secret prerequisite"); + super::install().expect("same scope installs idempotently"); assert_eq!(counter_scope_handle(), "instance"); - - if case.starts_with("installed-") { - assert_installed_policy_preserved(&case); - } else { - assert_resolved_policy(&case); - } + let scenario = match case.as_str() { + "absent" => "absent", + "anonymous" => "anonymous", + "missing-secret" => "missing-secret", + "missing-config" => "missing-config", + "malformed-config" => "malformed-config", + _ => panic!("unknown scenario"), + }; + assert_routes_still_dispatch(scenario); } #[test] - fn health_faults_close_only_health_and_preserve_frozen_policy() { - for (case, diagnostics) in [ - ("malformed", 2), - ("malformed-with-token", 2), - ("token-in-flag", 2), - ("padded-token-in-flag", 2), - ("token-in-flag-with-token", 2), - ("blank", 2), - ("conflicting", 2), - ("absent", 0), - ("default", 0), - ("empty-token", 0), - ("open", 0), - ("token", 0), - ("installed-open", 1), - ("installed-token", 1), - ("installed-scope", 1), + fn runtime_policy_controls_health_without_provider_boot_credentials() { + for case in [ + "absent", + "anonymous", + "missing-secret", + "missing-config", + "malformed-config", + "scope-conflict", ] { - let output = Command::new(env::current_exe().expect("test binary")) + let output = Command::new(env::current_exe().unwrap()) .args([ "--exact", - "health::tests::health_policy_child", + "health::tests::health_scope_child", "--nocapture", ]) .env(CHILD, case) .output() - .expect("isolated health process"); + .unwrap(); let logs = format!( "{}{}", String::from_utf8_lossy(&output.stdout), @@ -446,42 +321,10 @@ mod tests { ); assert!(output.status.success(), "{case}: {logs}"); assert!(logs.contains("1 passed"), "the child must run: {logs}"); - assert_eq!( - logs.matches("health policy unresolved").count(), - diagnostics, - "{case}: malformed input is diagnosed, not silently ignored: {logs}" + assert!( + !logs.contains(TOKEN), + "health credentials must not appear in logs" ); - for diagnostic in logs - .lines() - .filter(|line| line.contains("health policy unresolved")) - { - assert!(diagnostic.contains("ERROR auctioneer::health:")); - if case != "conflicting" { - assert!( - diagnostic.contains(super::FLAG_VARIABLE), - "{case}: the rejected flag key must remain diagnosable: {logs}" - ); - assert!( - diagnostic.contains("not a strict boolean"), - "{case}: the wrong-type reason must remain diagnosable: {logs}" - ); - } - } - assert_no_health_credentials(&logs); - if matches!( - case, - "malformed" - | "malformed-with-token" - | "token-in-flag" - | "padded-token-in-flag" - | "token-in-flag-with-token" - ) { - let (flag, _) = scenario_inputs(case); - assert!( - !logs.contains(flag.expect("malformed flag scenario").trim()), - "the process logger must not expose the rejected flag" - ); - } } } } diff --git a/crates/auctioneer-adapter-spin/src/lib.rs b/crates/auctioneer-adapter-spin/src/lib.rs index 0f91a39..4563534 100644 --- a/crates/auctioneer-adapter-spin/src/lib.rs +++ b/crates/auctioneer-adapter-spin/src/lib.rs @@ -81,25 +81,13 @@ async fn handle(req: Request) -> anyhow::Result { || Arc::new(DelegateToDevStrategy), ) .map_err(|e| anyhow::anyhow!("auctioneer boot envelope rejected: {e}"))?; - install_health().await?; + install_health()?; run_app_with_platform::(req, profile.dispatch_platform).await } -/// Resolve the health-endpoint access policy from Spin variables and -/// install it. Runs from the request entrypoint (Spin instantiates per -/// request), so the install is idempotent: the first call in this -/// instance freezes the policy `OnceLock` — including the fail-closed -/// `Closed` policy when neither input resolves — and identical -/// reinstalls succeed while conflicting ones fail the request. -/// Resolution errors degrade only health to `Closed`, matching Fastly; -/// `/status` and auctions still dispatch unless installation conflicts. +/// Install the instance's immutable health observation scope. #[cfg(target_arch = "wasm32")] -async fn install_health() -> anyhow::Result<()> { - // `variables::get` is async and errors on an undeclared/unset - // variable; either way the input is absent and the decision table - // fail-closes. - let raw_flag = variables::get(health::FLAG_VARIABLE).await.ok(); - let raw_token = variables::get("health_gvl_token").await.ok(); - health::install(raw_flag.as_deref(), raw_token) - .map_err(|err| anyhow::anyhow!("health policy install conflicted: {err}")) +fn install_health() -> anyhow::Result<()> { + health::install() + .map_err(|err| anyhow::anyhow!("health counter scope install conflicted: {err}")) } diff --git a/crates/auctioneer-adapter-spin/tests/manifest_health_variables.rs b/crates/auctioneer-adapter-spin/tests/manifest_health_variables.rs index ce5c55f..3f6722e 100644 --- a/crates/auctioneer-adapter-spin/tests/manifest_health_variables.rs +++ b/crates/auctioneer-adapter-spin/tests/manifest_health_variables.rs @@ -1,117 +1,41 @@ -//! The shipped `spin.toml` can configure health authentication. -//! -//! Spin resolves a component variable only when the APPLICATION -//! declares it AND the component maps it. The manifest shipped -//! neither, so `variables::get` always errored, the adapter read both -//! inputs as absent, and the decision table fail-closed to 401 with no -//! way for an operator to supply a token — `spin up --variable` and -//! runtime config providers can only set a DECLARED variable. -//! -//! # Why the manifest text -//! -//! The failure is a deployment-configuration one, so the check belongs -//! on the artifact an operator deploys. Two measurements set the shape -//! of this file: -//! -//! - `spin up` DOES reject the missing declaration, with -//! `invalid variable template: unknown variable -//! "allow_unauthenticated_health"` — but reaching it needs a built -//! `.wasm` and a listening server, which this crate's tests have -//! neither of; -//! - `spin doctor` does NOT catch it. With the declaration removed it -//! still reports "No problems found", so it cannot stand in. -//! -//! The cookie-key manifest target also drives the pinned Spin loader; -//! these assertions pin the health defaults and component mappings. +//! The SDK secret reader needs an application declaration and component mapping. #[cfg(test)] mod tests { - use std::fs::read_to_string; - - /// The two inputs `install_health` reads on this target. - const HEALTH_VARIABLES: [&str; 2] = ["allow_unauthenticated_health", "health_gvl_token"]; - - fn manifest() -> String { - read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spin.toml")) - .expect("the adapter's own manifest is readable from its test") - } - - /// Both variables are declared, and both are mapped into the - /// component that reads them. - /// - /// Asserted as two separate facts because satisfying one alone - /// does not work: a declaration with no mapping leaves the reads - /// failing exactly as if nothing were declared, and a mapping with - /// no declaration makes Spin refuse the app outright. #[test] - fn both_health_variables_are_declared_and_mapped() { - let manifest = manifest(); - - let (app_section, component_section) = manifest - .split_once("[component.auctioneer-adapter-spin.variables]") - .expect( - "the manifest must carry a [component.auctioneer-adapter-spin.variables] \ - table: a declared-but-unmapped variable does not resolve", - ); - + fn health_secret_is_declared_secret_and_mapped_without_an_adapter_policy_flag() { + let manifest: toml::Value = toml::from_str(include_str!("../spin.toml")).unwrap(); + let variables = manifest["variables"].as_table().unwrap(); + let token = &variables["health_gvl_token"]; + assert_eq!(token["secret"].as_bool(), Some(true)); + assert_eq!(token["default"].as_str(), Some("")); + assert_eq!( + manifest["component"]["auctioneer-adapter-spin"]["variables"]["health_gvl_token"] + .as_str(), + Some("{{ health_gvl_token }}") + ); + assert!(!variables.contains_key("allow_unauthenticated_health")); assert!( - app_section.contains("[variables]"), - "the manifest must declare an application [variables] table", + manifest["component"]["auctioneer-adapter-spin"]["variables"] + .get("allow_unauthenticated_health") + .is_none() ); - for name in HEALTH_VARIABLES { - assert!( - app_section.contains(name), - "[variables] must declare {name}: the adapter reads it through \ - `variables::get`, and an undeclared variable can be set by \ - nothing -- not `spin up --variable`, not a runtime config provider", - ); - assert!( - component_section.contains(name), - "the component must map {name} for the read to resolve", - ); - } } - /// The token is a credential, and the flag fails closed. - /// - /// Both are properties of the DEFAULTS rather than of the wiring: a - /// token not marked `secret` sits in the deployed manifest surface, - /// and a flag with no default makes Spin refuse the app instead of - /// applying the fail-closed policy an operator who configures - /// nothing should get. #[test] - fn the_token_is_secret_and_the_flag_defaults_closed() { - let manifest = manifest(); - let declaration = manifest - .lines() - .find(|line| line.trim_start().starts_with("health_gvl_token =")) - .expect("health_gvl_token is declared"); - assert!( - declaration.contains("secret = true"), - "health_gvl_token is a credential, so it must be declared `secret`; \ - found {declaration:?}", - ); - let parsed: toml::Value = toml::from_str(&manifest).unwrap(); - assert_eq!( - parsed["variables"]["health_gvl_token"] - .get("default") - .and_then(toml::Value::as_str), - Some(""), - "an empty default makes the optional variable valid without a credential" - ); - - let flag = manifest - .lines() - .find(|line| { - line.trim_start() - .starts_with("allow_unauthenticated_health =") - }) - .expect("allow_unauthenticated_health is declared"); - assert!( - flag.contains(r#"default = "false""#), - "the flag must default to \"false\" so an operator who sets nothing \ - gets the fail-closed policy rather than an unresolved variable; \ - found {flag:?}", - ); + fn spin_boot_does_not_read_health_policy_or_credentials() { + let source = include_str!("../src/lib.rs"); + assert!(source.contains("install_health()?")); + for removed in [ + "health::FLAG_VARIABLE", + "variables::get(\"health_gvl_token\")", + "raw_token", + "raw_flag", + ] { + assert!( + !source.contains(removed), + "provider boot must not resolve {removed}" + ); + } } } diff --git a/crates/auctioneer-cli/src/application.rs b/crates/auctioneer-cli/src/application.rs index 9ff34b6..1b7d3bd 100644 --- a/crates/auctioneer-cli/src/application.rs +++ b/crates/auctioneer-cli/src/application.rs @@ -344,6 +344,8 @@ mod config_contract { ], ), ("features", &["gzip_outbound", "returnallbidstatus_default"]), + ("gvl", &["authenticated_health"]), + ("gvl.authenticated_health", &["enabled", "token_secret"]), ]; #[test] diff --git a/crates/auctioneer-config/src/policy.rs b/crates/auctioneer-config/src/policy.rs index 3f79a20..6349a37 100644 --- a/crates/auctioneer-config/src/policy.rs +++ b/crates/auctioneer-config/src/policy.rs @@ -48,7 +48,9 @@ impl<'de> Deserialize<'de> for ApplicationSchemaVersion { } } -pub use object_models::{AccountPolicy, HostPolicy, OperatorPolicy}; +pub use object_models::{ + AccountPolicy, AuthenticatedHealthPolicy, GvlPolicy, HostPolicy, OperatorPolicy, +}; #[expect( clippy::same_name_method, @@ -97,6 +99,41 @@ mod object_models { skip_serializing_if = "Option::is_none" )] pub floor_currency: Option, + /// GVL diagnostics access policy; secret values remain in the default Secret Store. + #[serde( + default, + deserialize_with = "deserialize_optional_value", + skip_serializing_if = "Option::is_none" + )] + pub gvl: Option, + } + + /// GVL-specific runtime policy. Omitted health settings keep the endpoint closed. + #[derive(Debug, Default, Deserialize, Serialize, Validate)] + #[serde(remote = "Self", deny_unknown_fields)] + pub struct GvlPolicy { + #[serde( + default, + deserialize_with = "deserialize_optional_value", + skip_serializing_if = "Option::is_none" + )] + #[validate(nested)] + pub authenticated_health: Option, + } + + /// Explicit GVL health access mode and optional secret entry reference. + #[derive(Debug, Deserialize, Serialize)] + #[serde(remote = "Self", deny_unknown_fields)] + pub struct AuthenticatedHealthPolicy { + /// True requires a token; false deliberately permits anonymous health requests. + pub enabled: bool, + /// Entry in the declared default Secret Store. Required only when enabled. + #[serde( + default, + deserialize_with = "deserialize_optional_value", + skip_serializing_if = "Option::is_none" + )] + pub token_secret: Option, } /// Optional host policy requests over product defaults. @@ -235,6 +272,7 @@ impl Default for OperatorPolicy { bidders: BTreeMap::new(), features: FeatureFlags::default(), floor_currency: None, + gvl: None, } } } @@ -276,6 +314,9 @@ impl Validate for OperatorPolicy { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); errors.merge_self("host", validate_host_policy(&self.host)); + if let Some(gvl) = &self.gvl { + errors.merge_self("gvl", gvl.validate()); + } errors.merge_self( "account_defaults", validate_account_policy(&self.account_defaults), @@ -305,6 +346,65 @@ impl fmt::Debug for OperatorPolicy { } } +impl<'de> ObjectFields<'de> for GvlPolicy { + fn deserialize_fields>(deserializer: D) -> Result { + Self::deserialize(deserializer) + } +} + +impl<'de> Deserialize<'de> for GvlPolicy { + fn deserialize>(deserializer: D) -> Result { + deserialize_fields(deserializer) + } +} + +impl Serialize for GvlPolicy { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +impl<'de> ObjectFields<'de> for AuthenticatedHealthPolicy { + fn deserialize_fields>(deserializer: D) -> Result { + Self::deserialize(deserializer) + } +} + +impl<'de> Deserialize<'de> for AuthenticatedHealthPolicy { + fn deserialize>(deserializer: D) -> Result { + deserialize_fields(deserializer) + } +} + +impl Serialize for AuthenticatedHealthPolicy { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +impl Validate for AuthenticatedHealthPolicy { + fn validate(&self) -> Result<(), ValidationErrors> { + let mut errors = ValidationErrors::new(); + let valid = match (self.enabled, self.token_secret.as_deref()) { + (true, Some(reference)) => { + !reference.is_empty() + && reference.len() <= 128 + && reference.trim() == reference + && !reference.bytes().any(|byte| byte.is_ascii_control()) + } + (false, None) => true, + (true, None) | (false, Some(_)) => false, + }; + if !valid { + errors.add( + "token_secret", + ValidationError::new("invalid_health_token_reference"), + ); + } + structural_validation_result(errors) + } +} + impl<'de> ObjectFields<'de> for HostPolicy { fn deserialize_fields>(deserializer: D) -> Result { Self::deserialize(deserializer) @@ -553,6 +653,83 @@ mod tests { use serde_json::{Value, json}; use validator::Validate as _; + #[test] + fn gvl_authenticated_health_round_trips_both_explicit_modes() { + for health in [ + json!({"enabled": true, "token_secret": "health_gvl_token"}), + json!({"enabled": false}), + ] { + let input = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": health}}); + let policy: OperatorPolicy = serde_json::from_value(input.clone()).unwrap(); + policy.validate().unwrap(); + let output = serde_json::to_value(&policy).unwrap(); + assert_eq!(output["gvl"], input["gvl"]); + let restored: OperatorPolicy = + toml::from_str(&toml::to_string(&policy).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), output); + } + let policy: OperatorPolicy = + serde_json::from_value(json!({"schema_version": 1_u8})).unwrap(); + assert!(serde_json::to_value(policy).unwrap().get("gvl").is_none()); + } + + #[test] + fn gvl_authenticated_health_rejects_incomplete_conflicting_and_invalid_shapes() { + for gvl in [ + json!(null), + json!([]), + json!({"unknown": true}), + json!({"authenticated_health": null}), + json!({"authenticated_health": []}), + json!({"authenticated_health": {}}), + json!({"authenticated_health": {"enabled": "true", "token_secret": "token"}}), + json!({"authenticated_health": {"enabled": null}}), + json!({"authenticated_health": {"enabled": false, "unknown": true}}), + json!({"authenticated_health": {"enabled": false, "token_secret": null}}), + ] { + serde_json::from_value::(json!({"schema_version": 1_u8, "gvl": gvl})) + .expect_err("GVL health policy requires strict objects and field types"); + } + for health in [ + json!({"enabled": true}), + json!({"enabled": false, "token_secret": "token"}), + ] { + let policy: OperatorPolicy = serde_json::from_value( + json!({"schema_version": 1_u8, "gvl": {"authenticated_health": health}}), + ) + .unwrap(); + policy + .validate() + .expect_err("health mode and token reference must agree"); + } + } + + #[test] + fn gvl_authenticated_health_validates_reference_bytes_without_logging_values() { + for reference in ["x".to_owned(), "x".repeat(128), "\u{00e9}".repeat(64)] { + let policy: OperatorPolicy = serde_json::from_value(json!({"schema_version": 1_u8, + "gvl": {"authenticated_health": {"enabled": true, "token_secret": reference}}})) + .unwrap(); + policy.validate().unwrap(); + } + for reference in [ + String::new(), + "x".repeat(129), + "\u{00e9}".repeat(65), + " reference_canary".to_owned(), + "reference_canary ".to_owned(), + "reference_canary\n".to_owned(), + "reference\tcanary".to_owned(), + "reference\0canary".to_owned(), + ] { + let policy: OperatorPolicy = serde_json::from_value(json!({"schema_version": 1_u8, + "gvl": {"authenticated_health": {"enabled": true, "token_secret": reference}}})) + .unwrap(); + let error = policy.validate().unwrap_err(); + assert!(!format!("{error:?}").contains("reference_canary")); + } + } + fn assert_no_null(value: &Value) { match value { Value::Object(fields) => fields.values().for_each(assert_no_null), diff --git a/crates/auctioneer-config/tests/root_shape.rs b/crates/auctioneer-config/tests/root_shape.rs index edffb76..ae3a090 100644 --- a/crates/auctioneer-config/tests/root_shape.rs +++ b/crates/auctioneer-config/tests/root_shape.rs @@ -29,7 +29,8 @@ mod tests { "accounts": {"fake_publisher": {"debug_allow": true}}, "bidders": {"fake_bidder": {"implementation": "fake", "settings": {}, "enabled": false}}, "features": {"gzip_outbound": false}, - "floor_currency": {"expires_at": 2000_i64, "usd_per_unit": {"EUR": 1.25_f64}} + "floor_currency": {"expires_at": 2000_i64, "usd_per_unit": {"EUR": 1.25_f64}}, + "gvl": {"authenticated_health": {"enabled": true, "token_secret": "health_gvl_token"}} }) } @@ -59,6 +60,7 @@ mod tests { input["bidders"]["fake_bidder"]["settings"] ); assert_eq!(canonical["floor_currency"], input["floor_currency"]); + assert_eq!(canonical["gvl"], input["gvl"]); let restored: Document = toml::from_str(&toml::to_string(&toml_policy).unwrap()).unwrap(); assert_eq!(serde_json::to_value(restored).unwrap(), canonical); } @@ -147,7 +149,13 @@ mod tests { let root_error = serde_json::from_value::(json!([1_u64, {}, {}, {}, {}, {}])).unwrap_err(); assert_eq!(root_error.classify(), Category::Data); - for path in ["/host", "/account_defaults", "/accounts/fake_publisher"] { + for path in [ + "/host", + "/account_defaults", + "/accounts/fake_publisher", + "/gvl", + "/gvl/authenticated_health", + ] { for value in [ json!([]), json!(null), @@ -179,6 +187,8 @@ mod tests { "/bidders/fake_bidder/settings", "/features", "/floor_currency", + "/gvl", + "/gvl/authenticated_health", ] { let mut document = fixture(); document diff --git a/crates/auctioneer-core/src/auction/audit.rs b/crates/auctioneer-core/src/auction/audit.rs index 140958e..4bd3e83 100644 --- a/crates/auctioneer-core/src/auction/audit.rs +++ b/crates/auctioneer-core/src/auction/audit.rs @@ -564,7 +564,14 @@ mod tests { ); assert_eq!(additional, owned_layout_bytes()); #[cfg(all(target_arch = "aarch64", target_os = "macos"))] - assert_eq!(additional, 19_160, "reviewed native owner layout snapshot"); + assert_eq!( + additional, + // Preserve the prior owner-layout baseline. The optional GVL policy + // adds 96 measured bytes to the native execution frame; no other + // prepared/failure/ledger owner changes in this migration. + 19_160 + 96, + "reviewed native owner layout plus GVL policy frame growth" + ); } fn owned_layout_bytes() -> u64 { diff --git a/crates/auctioneer-core/src/config/runtime.rs b/crates/auctioneer-core/src/config/runtime.rs index adf473a..d9ae14f 100644 --- a/crates/auctioneer-core/src/config/runtime.rs +++ b/crates/auctioneer-core/src/config/runtime.rs @@ -1,24 +1,20 @@ //! The single runtime-config resolution owner. //! //! Every config-consuming route (`/openrtb2/auction`, `/status`, -//! `/info/bidders`, `/info/bidders/`) goes through +//! `/info/bidders`, `/info/bidders/`, `/_auctioneer/health/gvl`) goes through //! [`resolve_runtime_config`] rather than calling the `EdgeZero` -//! `AppConfig` extractor itself, so the load policy — and the wire shape -//! of a load failure — is defined in exactly one place. +//! `AppConfig` extractor itself, so loading and fault classification have +//! one owner. //! -//! That policy is fail-closed: a missing, corrupt or untrusted blob is a 500, -//! so is a missing binding or a store key the backend refuses, and a store outage is a 503, -//! because serving any of them from the baked default would silently -//! turn GDPR enforcement off. See [`resolve_runtime_config`] for the -//! per-class reasoning. +//! Auction and catalog routes render permanent config faults as 500 and +//! transient store outages as 503 through [`RuntimeConfigError::into_response`]. +//! Serving auctions from baked defaults after either fault could silently +//! turn GDPR enforcement off. GVL health instead maps every config fault to +//! its authorization protocol's 401 response; it never falls back to open access. //! -//! Fail-closed is the default, not a universal rule. It buys exactly one -//! thing — no privacy decision made from config nobody can vouch for — so -//! a route that makes NO privacy decision pays its availability cost for -//! nothing. Such a route calls [`RuntimeConfigError::degrade_to_default`] -//! instead of [`RuntimeConfigError::into_response`]. Both dispositions -//! live here so a reader sees which routes take which, and why, in one -//! place rather than reconstructing it from four handlers. +//! `/status` is the independent liveness exception. It records the config fault +//! and calls [`RuntimeConfigError::degrade_to_default`] because reporting that +//! fault reaches neither a privacy nor an authorization decision. use crate::bidder_registry::{cached_registry_error, registry_handle}; use crate::config::{AuctioneerAppConfig, SdkAuctioneerAppConfig}; @@ -315,8 +311,10 @@ impl RuntimeConfigError { /// Cached registry failures take precedence, and cold registry construction and /// store reads require the context's complete captured extraction tuple to fit /// the funded host declaration. Backend/secret outages and deadline expiry stay -/// transient (503 with `Retry-After`); invalid keys retain their distinct refusal -/// code and permanent 500 disposition. The request context's declared default +/// transient (503 with `Retry-After` when rendered by `into_response`); invalid keys +/// retain their distinct refusal code and permanent 500 disposition. GVL health +/// maps all such errors to its fail-closed 401 authorization response instead. +/// The request context's declared default /// config-store binding selects both the store and its entry key. /// /// `/status` alone may explicitly take [`RuntimeConfigError::degrade_to_default`] diff --git a/crates/auctioneer-core/src/handlers/health_gvl.rs b/crates/auctioneer-core/src/handlers/health_gvl.rs index e56ff06..3054c7d 100644 --- a/crates/auctioneer-core/src/handlers/health_gvl.rs +++ b/crates/auctioneer-core/src/handlers/health_gvl.rs @@ -1,9 +1,9 @@ //! `GET /_auctioneer/health/gvl` handler + the rolling GVL //! unavailable-lookup counter. //! -//! The handler is fail-closed: it answers 401 unless the adapter boot -//! installed an explicit `HealthAccessPolicy` (see -//! `host_primitives::registry`). The counters are the process-local +//! The handler is fail-closed: the typed runtime GVL policy selects either +//! explicit anonymous access or authentication through the default Secret Store. +//! Missing policy or an unavailable credential answers 401. The counters are process-local //! primitives the auction handler records into when the privacy //! resolver emits `GvlVersionUnavailable` or `GclStale` warnings; this //! module ships the primitives and the read side. @@ -14,8 +14,10 @@ //! months, the GCL's RFC 9111 lifetime is days — and either one alone //! makes every enforced auction fail closed. +use crate::config::runtime::{CONFIG_FETCH_TIMEOUT, resolve_runtime_config}; use crate::handlers::aux_egress::with_aux_egress; use crate::host_primitives::registry::{self as host_registry, TestClockHandle}; +use crate::host_primitives::{BearerToken, HealthAuth as _}; use crate::serialize::serialize_json_exact; use auctioneer_runtime::Clock; use edgezero_core::action; @@ -23,6 +25,8 @@ use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Response, StatusCode, response_builder}; +use edgezero_core::store_registry::BoundSecretStore; +use edgezero_core::time::{Deadline, MonotonicClock}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -122,10 +126,6 @@ pub async fn health_gvl(ctx: RequestContext) -> Result { } async fn health_gvl_body(ctx: RequestContext) -> Result { - // Authorize through `health_authorize`, which FAIL-CLOSES when no - // policy was installed. The token comes from the ADAPTER-installed - // policy, never `std::env` (empty on Workers). - // // The `Bearer` scheme name is CASE-INSENSITIVE (RFC 9110 §11.1) // and may be followed by more than one SPACE, so the scheme token // matches case-insensitively and leading SPACES are trimmed off @@ -148,17 +148,13 @@ async fn health_gvl_body(ctx: RequestContext) -> Result { .eq_ignore_ascii_case("bearer") .then(|| rest.trim_start_matches(' ')) }); - if matches!( - host_registry::health_authorize(presented), - host_registry::HealthAuthz::Deny - ) { - // Covers all three deny paths: no policy installed (the - // fail-closed default), Token policy with a missing or wrong - // bearer. A 401 MUST carry a `WWW-Authenticate` challenge - // (RFC 9110 §11.6.1). + if !authorize(&ctx, presented).await { + // Every denial carries a Bearer challenge, including unavailable policy + // and secret reads. Neither the reason nor credential reaches the wire. return response_builder() .status(StatusCode::UNAUTHORIZED) .header("WWW-Authenticate", "Bearer") + .header("cache-control", "no-store") .body(Body::from_bytes(b"unauthorized".to_vec())) .map_err(EdgeError::internal); } @@ -239,13 +235,284 @@ async fn health_gvl_body(ctx: RequestContext) -> Result { response_builder() .status(status_code) .header("content-type", "application/json") + .header("cache-control", "no-store") .body(Body::from_bytes(body_bytes)) .map_err(EdgeError::internal) } +// The aux admission receipt funds config extraction and signing work through +// terminal egress. This payload fits within its existing 4096-byte keyring term. +// Provider APIs can materialize more before refusing: this is an accepted-size +// bound, with cooperative deadline checks on synchronous provider operations. +const MAX_HEALTH_TOKEN_BYTES: u64 = 4096; + +async fn authorize(ctx: &RequestContext, presented: Option<&str>) -> bool { + let clock = ctx.monotonic_clock(); + let deadline = clock.deadline_after(CONFIG_FETCH_TIMEOUT); + // Move out only this small policy; the rest of the config is dropped before + // secret materialization, and no credential is retained in config snapshots. + let configured_health = { + let policy = match resolve_runtime_config(ctx).await { + Ok(policy) => policy, + Err(error) => { + log::error!(target: "auctioneer::health", "GVL health policy unavailable: {}", error.status_code().as_str()); + return false; + } + }; + policy.gvl.and_then(|gvl| gvl.authenticated_health) + }; + let Some(health) = configured_health else { + return false; + }; + if deadline.is_expired_at(clock.now()) { + return false; + } + if !health.enabled { + return true; + } + let (Some(reference), Some(bearer)) = (health.token_secret, presented) else { + return false; + }; + let Some(auth) = read_token(ctx.secret_store_default(), &reference, &clock, deadline).await + else { + log::error!(target: "auctioneer::health", "GVL health token unavailable in default Secret Store"); + return false; + }; + auth.verify(bearer) +} + +async fn read_token( + binding: Option, + reference: &str, + clock: &MonotonicClock, + deadline: Deadline, +) -> Option { + if deadline.is_expired_at(clock.now()) { + return None; + } + let result = binding? + .get_bytes_bounded( + reference, + clock, + deadline, + MAX_HEALTH_TOKEN_BYTES, + MAX_HEALTH_TOKEN_BYTES, + ) + .await + .ok()?; + if deadline.is_expired_at(clock.now()) || result.backend_bytes > MAX_HEALTH_TOKEN_BYTES { + return None; + } + let bytes = result.value?; + if u64::try_from(bytes.len()).ok()? > MAX_HEALTH_TOKEN_BYTES { + return None; + } + BearerToken::new(bytes) +} + fn clock_handle(ctx: &RequestContext) -> Arc { if let Some(handle) = ctx.extensions().get::() { return Arc::clone(&handle.0); } host_registry::clock_handle() } + +#[cfg(test)] +mod tests { + use super::read_token; + use crate::host_primitives::HealthAuth as _; + use bytes::Bytes; + use edgezero_core::{ + config_store::BoundedStoreRead, + secret_store::{SecretError, SecretHandle, SecretStore}, + store_registry::BoundSecretStore, + time::{Deadline, MonotonicClock, MonotonicInstant}, + }; + use futures::{executor::block_on, task::noop_waker}; + use std::{ + future::{Future as _, pending}, + pin::pin, + str::from_utf8, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + time::Duration, + }; + + struct Provider { + calls: AtomicUsize, + finished: AtomicBool, + dropped: Arc, + pending: bool, + error: bool, + backend_bytes: u64, + value: Option, + } + + struct DropGuard(Arc); + #[expect( + clippy::missing_trait_methods, + reason = "Drop::pin_drop is unstable and mutually exclusive with Drop::drop" + )] + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[async_trait::async_trait(?Send)] + impl SecretStore for Provider { + async fn get_bytes(&self, _: &str, _: &str) -> Result, SecretError> { + panic!("unbounded health secret reads are forbidden"); + } + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + _: &MonotonicClock, + _: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + assert_eq!((store_name, key), ("selected_store", "selected_token")); + assert_eq!((max_backend_bytes, max_value_bytes), (4096, 4096)); + self.calls.fetch_add(1, Ordering::SeqCst); + let _guard = DropGuard(Arc::clone(&self.dropped)); + if self.pending { + pending::<()>().await; + } + self.finished.store(true, Ordering::SeqCst); + if self.error { + return Err(SecretError::DeadlineExceeded); + } + Ok(BoundedStoreRead { + backend_bytes: self.backend_bytes, + value: self.value.clone(), + }) + } + } + + fn provider(value: Option, backend_bytes: u64) -> Provider { + Provider { + calls: AtomicUsize::new(0), + finished: AtomicBool::new(false), + dropped: Arc::new(AtomicBool::new(false)), + pending: false, + error: false, + backend_bytes, + value, + } + } + + fn binding(provider: &Arc) -> BoundSecretStore { + let concrete = Arc::clone(provider); + let shared: Arc = concrete; + BoundSecretStore::new(SecretHandle::new(shared), "selected_store".to_owned()) + } + + #[test] + fn token_read_rejects_expiry_missing_empty_backend_and_value_limits_independently() { + for (value, backend_bytes, expired, error, allowed) in [ + (Some(Bytes::from_static(b"t")), 1, false, false, true), + ( + Some(Bytes::from(vec![b't'; 4096])), + 4096, + false, + false, + true, + ), + (Some(Bytes::from_static(b"t")), 1, true, false, false), + (Some(Bytes::from_static(b"t")), 1, false, true, false), + (Some(Bytes::from_static(b"t")), 4097, false, false, false), + (Some(Bytes::from(vec![b't'; 4097])), 1, false, false, false), + (Some(Bytes::new()), 0, false, false, false), + (None, 0, false, false, false), + ] { + let mut concrete = provider(value, backend_bytes); + concrete.error = error; + let provider = Arc::new(concrete); + let clock = MonotonicClock::default(); + let deadline = clock.deadline_after(if expired { + Duration::ZERO + } else { + Duration::from_secs(1) + }); + let result = block_on(read_token( + Some(binding(&provider)), + "selected_token", + &clock, + deadline, + )); + assert_eq!(result.is_some(), allowed); + assert_eq!(provider.calls.load(Ordering::SeqCst), usize::from(!expired)); + if let Some(auth) = result { + let bytes = provider.value.as_ref().unwrap(); + assert!(auth.verify(from_utf8(bytes).unwrap())); + } + } + let clock = MonotonicClock::default(); + assert!( + block_on(read_token( + None, + "selected_token", + &clock, + clock.deadline_after(Duration::from_secs(1)) + )) + .is_none() + ); + } + + #[test] + fn token_read_rechecks_deadline_after_provider_work() { + let provider = Arc::new(provider(Some(Bytes::from_static(b"t")), 1)); + let anchor = MonotonicInstant::now(); + let observed = Arc::clone(&provider); + let clock = MonotonicClock::new(move || { + if observed.finished.load(Ordering::SeqCst) { + anchor.checked_add(Duration::from_secs(2)).unwrap() + } else { + anchor + } + }); + let deadline = clock.deadline_after(Duration::from_secs(1)); + assert!( + block_on(read_token( + Some(binding(&provider)), + "selected_token", + &clock, + deadline + )) + .is_none() + ); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn token_read_is_lazy_and_cancellation_drops_provider_future() { + let mut concrete = provider(Some(Bytes::from_static(b"t")), 1); + concrete.pending = true; + let provider = Arc::new(concrete); + let clock = MonotonicClock::default(); + { + let future = read_token( + Some(binding(&provider)), + "selected_token", + &clock, + clock.deadline_after(Duration::from_secs(1)), + ); + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + let mut pinned = pin!(future); + assert!(matches!( + pinned + .as_mut() + .poll(&mut Context::from_waker(&noop_waker())), + Poll::Pending + )); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); + assert!(!provider.dropped.load(Ordering::SeqCst)); + }; + assert!(provider.dropped.load(Ordering::SeqCst)); + } +} diff --git a/crates/auctioneer-core/src/host_primitives/mod.rs b/crates/auctioneer-core/src/host_primitives/mod.rs index a39a4cf..e55367a 100644 --- a/crates/auctioneer-core/src/host_primitives/mod.rs +++ b/crates/auctioneer-core/src/host_primitives/mod.rs @@ -41,11 +41,10 @@ pub use ingress_lease::{ }; pub use race::BiasedRace3; pub use registry::{ - BearerToken, HealthAccessPolicy, HealthAuth, HealthAuthz, HealthBootError, TestClockHandle, - TestMonotonicClockHandle, TestRngHandle, TestStrategyHandle, TestTimerHandle, clock_handle, - counter_scope_handle, decide_health_policy, decode_cooperator_handle, health_authorize, - health_policy_handle, install_health_primitives, install_health_primitives_without_policy, - read_bool_flag, rng_handle, strategy_handle, timer_handle, + BearerToken, HealthAuth, HealthBootError, TestClockHandle, TestMonotonicClockHandle, + TestRngHandle, TestStrategyHandle, TestTimerHandle, clock_handle, counter_scope_handle, + decode_cooperator_handle, install_health_counter_scope, rng_handle, strategy_handle, + timer_handle, }; pub use request_clock::RequestClock; pub use resource_profile::ResourceProfile; diff --git a/crates/auctioneer-core/src/host_primitives/registry.rs b/crates/auctioneer-core/src/host_primitives/registry.rs index 942681f..2bf56fd 100644 --- a/crates/auctioneer-core/src/host_primitives/registry.rs +++ b/crates/auctioneer-core/src/host_primitives/registry.rs @@ -168,67 +168,17 @@ pub struct TestTimerHandle(pub Arc); pub struct TestStrategyHandle(pub Arc); /// Authenticator for the `/_auctioneer/health/gvl` bearer token. The -/// token is resolved BY THE ADAPTER from its own host surface and -/// installed as a typed primitive — portable core never reads -/// `std::env` (empty on Workers anyway). +/// token is resolved from the request-bound default Secret Store; portable +/// core never reads provider environment variables. pub trait HealthAuth: Send + Sync { /// Constant-time compare against the configured token. /// Implementations MUST NOT short-circuit on first byte mismatch. fn verify(&self, presented: &str) -> bool; - /// Stable identity for the idempotent-reinstall check (the 32-byte - /// digest for [`BearerToken`]). Compared instead of the `Arc` - /// pointer so two authenticators built from the same secret are - /// equal. + /// Stable identity of the configured credential (the 32-byte digest for + /// [`BearerToken`]). Never expose it through diagnostics. fn identity(&self) -> [u8; 32]; } -/// An EXPLICIT health-endpoint access policy — there is no implicit -/// "open" state. Absence of an installed policy is fail-closed (401). -pub enum HealthAccessPolicy { - /// Bearer token required (the common production case). - Token(Arc), - /// Endpoint served WITHOUT auth — installed only when the deploy - /// set `allow_unauthenticated_health = true` and is relying on an - /// ingress guard. The explicit variant makes "open" a deliberate, - /// greppable choice. - UnauthenticatedAcknowledged, - /// FROZEN fail-closed. Installed at boot when the adapter resolved - /// no token and no opt-in, so a LATER request (Cloudflare / Fastly - /// / Spin install from request entrypoints) cannot flip the - /// endpoint open — the `OnceLock` is already set, so any - /// conflicting reinstall is a `ConflictingPolicy` error. `Closed` - /// authorizes as `Deny`, same as never-set. - Closed, -} - -/// Hand-written because a `dyn` trait object can't derive it; `Token` -/// compares by `identity()`, not the `Arc` pointer, so two -/// authenticators built from the same secret compare equal. -#[expect( - clippy::missing_trait_methods, - reason = "PartialEq::ne's provided default is correct; only eq carries the identity comparison" -)] -impl PartialEq for HealthAccessPolicy { - fn eq(&self, other: &Self) -> bool { - use HealthAccessPolicy::{Closed, Token, UnauthenticatedAcknowledged}; - match (self, other) { - (UnauthenticatedAcknowledged, UnauthenticatedAcknowledged) | (Closed, Closed) => true, - (Token(a), Token(b)) => a.identity() == b.identity(), - _ => false, - } - } -} - -static HEALTH_POLICY: OnceLock = OnceLock::new(); - -/// `None` (never installed) OR `Some(Closed)` → handler fail-closes to -/// 401. The two differ only in mutability: once `Closed` is installed -/// the cell is frozen. -#[must_use] -pub fn health_policy_handle() -> Option<&'static HealthAccessPolicy> { - HEALTH_POLICY.get() -} - /// The adapter's declared counter scope. The rolling /// unavailable-lookup counter is process-local, and "process" means /// something different per platform (Cloudflare `"isolate"`, Fastly @@ -244,171 +194,37 @@ pub fn counter_scope_handle() -> &'static str { COUNTER_SCOPE.get().copied().unwrap_or("unknown") } -/// Dedicated health-boot error type — the admission stack's boot error -/// belongs to a later capability and this path must not depend on it. +/// Counter-scope installation failed because another adapter already owns it. #[derive(Debug, thiserror::Error)] pub enum HealthBootError { - /// A different policy or counter scope was already installed. A - /// lost `OnceLock::set` race with an IDENTICAL policy is success - /// (idempotent); only a genuinely different installed value is a - /// conflict. - #[error("a conflicting health policy or counter scope was already installed")] - ConflictingPolicy, - /// An adapter-resolved health flag was present but not a strict - /// `"true"` / `"false"`. - #[error("health config value {key} is not a strict boolean")] - NonBooleanFlag { - /// The static adapter-side key. Rejected values may be credentials. - key: &'static str, - }, + #[error("a conflicting health counter scope was already installed")] + ConflictingScope, } -/// Strict boolean parse of an adapter-resolved health flag. Absent → -/// `false`. Present-but-not-`true`/`false` → boot error (never a -/// silent `false`). -/// -/// # Errors -/// [`HealthBootError::NonBooleanFlag`] when the trimmed value is -/// anything other than `"true"` or `"false"`. -pub fn read_bool_flag(key: &'static str, raw: Option<&str>) -> Result { - match raw.map(str::trim) { - Some("true") => Ok(true), - None | Some("false") => Ok(false), - Some(_) => Err(HealthBootError::NonBooleanFlag { key }), - } -} - -/// The boot decision table every adapter runs over its resolved -/// `(token, allow_unauthenticated_health)` pair. Centralised here so -/// the four adapter boots share one implementation and the adapter -/// test suites can exercise the table directly. -/// -/// # Errors -/// [`HealthBootError::ConflictingPolicy`] when BOTH a token and the -/// unauthenticated opt-in were resolved — conflicting intent fails -/// boot rather than guessing. -pub fn decide_health_policy( - token: Option, - allow_unauthenticated: bool, -) -> Result { - match (token, allow_unauthenticated) { - // Conflicting intent — fail boot, don't guess. - (Some(_), true) => Err(HealthBootError::ConflictingPolicy), - (Some(tok), false) => Ok(HealthAccessPolicy::Token(Arc::new(tok))), - (None, true) => { - log::warn!( - target: "auctioneer::health", - "health_gvl endpoint is OPEN - relying on ingress restriction" - ); - Ok(HealthAccessPolicy::UnauthenticatedAcknowledged) - } - // No token, no opt-in → the frozen fail-closed policy; - // `health_authorize` answers 401 (attributable, not a hidden - // bad token). - (None, false) => Ok(HealthAccessPolicy::Closed), - } -} - -/// Install the health access policy + counter scope. Returns a -/// `Result` and does NOT silently discard `OnceLock::set` errors: a -/// second install is idempotent ONLY when the policy is -/// value-identical; a CONFLICTING second policy fails boot. -/// -/// Installation is not transactional: the policy is installed before the -/// counter scope. A scope conflict does not roll back that policy. Startup -/// callers must treat a conflict as fatal; this is not a reconfiguration API. +/// Install only the adapter's immutable observation scope. +/// Authentication policy is resolved from operator configuration per request. /// /// # Errors -/// [`HealthBootError::ConflictingPolicy`] when a different policy or -/// scope was already installed. -pub fn install_health_primitives( - policy: HealthAccessPolicy, - counter_scope: &'static str, -) -> Result<(), HealthBootError> { - set_policy(policy)?; +/// Returns [`HealthBootError::ConflictingScope`] for a different installed scope. +pub fn install_health_counter_scope(counter_scope: &'static str) -> Result<(), HealthBootError> { set_counter_scope(counter_scope) } -/// Race-IDEMPOTENT install. `OnceLock::set` returns the rejected value -/// on a lost race; two THREADS installing the identical policy must -/// BOTH succeed, so on `set` failure the value that actually won is -/// compared against ours. Only a genuinely different installed policy -/// is `ConflictingPolicy`. -fn set_policy(policy: HealthAccessPolicy) -> Result<(), HealthBootError> { - match HEALTH_POLICY.get() { - Some(existing) if *existing == policy => Ok(()), - Some(_) => Err(HealthBootError::ConflictingPolicy), - None => match HEALTH_POLICY.set(policy) { - Ok(()) => Ok(()), - // Lost the race: whoever won is now present. Identical → - // fine (idempotent); different → conflict. `set` hands the - // rejected value back; compare against the winner. - Err(rejected) => match HEALTH_POLICY.get() { - Some(winner) if *winner == rejected => Ok(()), - _ => Err(HealthBootError::ConflictingPolicy), - }, - }, - } -} - -/// Fail-loud AND race-idempotent — same pattern as `set_policy`. +/// Repeated identical installs, including lost races, succeed. fn set_counter_scope(counter_scope: &'static str) -> Result<(), HealthBootError> { match COUNTER_SCOPE.get() { Some(existing) if *existing == counter_scope => Ok(()), - Some(_) => Err(HealthBootError::ConflictingPolicy), + Some(_) => Err(HealthBootError::ConflictingScope), None => match COUNTER_SCOPE.set(counter_scope) { Ok(()) => Ok(()), Err(rejected) => match COUNTER_SCOPE.get() { Some(winner) if *winner == rejected => Ok(()), - _ => Err(HealthBootError::ConflictingPolicy), + _ => Err(HealthBootError::ConflictingScope), }, }, } } -/// Install the FROZEN `Closed` policy — the fail-closed default. Used -/// by an adapter that resolved no token and no opt-in. Leaving -/// `HEALTH_POLICY` unset instead would let a later request-entrypoint -/// install open the endpoint; SETTING the cell to `Closed` freezes it, -/// so any later conflicting install fails boot. -/// -/// # Errors -/// [`HealthBootError::ConflictingPolicy`] when a different policy or -/// scope was already installed. -pub fn install_health_primitives_without_policy( - counter_scope: &'static str, -) -> Result<(), HealthBootError> { - install_health_primitives(HealthAccessPolicy::Closed, counter_scope) -} - -/// The handler's authorization decision, computed from the INSTALLED -/// policy. Not installed OR `Closed` → 401 (fail-closed). The handler -/// returns early on `Deny` before touching the counter or building the -/// payload. -#[must_use] -pub fn health_authorize(presented_bearer: Option<&str>) -> HealthAuthz { - match health_policy_handle() { - // Fail-closed: a missing install is a misconfiguration, and - // 401 is the safe reading. - None | Some(HealthAccessPolicy::Closed) => HealthAuthz::Deny, - Some(HealthAccessPolicy::UnauthenticatedAcknowledged) => HealthAuthz::Allow, - Some(HealthAccessPolicy::Token(auth)) => match presented_bearer { - Some(tok) if auth.verify(tok) => HealthAuthz::Allow, - // Missing or wrong bearer → 401. - _ => HealthAuthz::Deny, - }, - } -} - -/// Outcome of [`health_authorize`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HealthAuthz { - /// Serve the health payload. - Allow, - /// Answer 401 with a `WWW-Authenticate: Bearer` challenge. - Deny, -} - /// The CONCRETE production authenticator. Constant-time so a timing /// side-channel can't recover the token byte by byte; hashing both /// sides to a fixed 32 bytes before the constant-time compare removes @@ -421,7 +237,7 @@ impl BearerToken { /// Digest a configured secret, rejecting an EMPTY one. An empty /// configured secret is a misconfiguration (a caller sending /// `Bearer ` or an empty header would otherwise authenticate). The - /// adapter maps `None` here to "no token configured". + /// health handler maps `None` here to an unavailable credential. #[must_use] pub fn new(token: T) -> Option where @@ -451,37 +267,17 @@ impl HealthAuth for BearerToken { #[cfg(test)] mod tests { - use super::{read_bool_flag, strategy_handle, timer_handle}; + use super::{strategy_handle, timer_handle}; #[test] - fn rejected_health_flags_never_retain_credentials_in_error_formatting() { - let secret = "misplaced-health-token-never-log-this"; - let key = "allow_unauthenticated_health"; - let error = read_bool_flag(key, Some(secret)).expect_err("a token is not a boolean"); - for rendered in [error.to_string(), format!("{error:?}")] { - assert!(rendered.contains(key), "the key is actionable diagnostics"); - assert!( - !rendered.contains(secret), - "rejected values are confidential" - ); - } - } - - #[test] - fn health_flags_only_accept_trimmed_lowercase_booleans() { - for (input, expected) in [ - (None, false), - (Some(" false "), false), - (Some(" true "), true), - ] { - assert_eq!(read_bool_flag("flag", input).unwrap(), expected); - } - for invalid in ["TRUE", "False", "yes", "", " "] { - assert!(matches!( - read_bool_flag("flag", Some(invalid)).unwrap_err(), - super::HealthBootError::NonBooleanFlag { key: "flag" } - )); - } + fn health_counter_scope_reinstalls_are_idempotent_and_conflicts_fail() { + super::install_health_counter_scope("process").unwrap(); + super::install_health_counter_scope("process").unwrap(); + assert!(matches!( + super::install_health_counter_scope("isolate"), + Err(super::HealthBootError::ConflictingScope) + )); + assert_eq!(super::counter_scope_handle(), "process"); } /// A missing install is a deployment bug and must be loud. The diff --git a/crates/auctioneer-core/tests/health_gvl.rs b/crates/auctioneer-core/tests/health_gvl.rs index f8406f9..71cc132 100644 --- a/crates/auctioneer-core/tests/health_gvl.rs +++ b/crates/auctioneer-core/tests/health_gvl.rs @@ -1,24 +1,19 @@ -//! `/_auctioneer/health/gvl` payload + counter tests under the -//! `UnauthenticatedAcknowledged` policy. -//! -//! `HEALTH_POLICY` is a process-global `OnceLock`, so ONE policy state -//! exists per test binary: this file owns the acknowledged state, -//! `health_gvl_failclosed.rs` owns never-installed, and -//! `health_gvl_auth.rs` owns Token. Every test here (re)installs the -//! same policy — identical concurrent installs are race-idempotent by -//! design, so parallel tests need no coordination. +//! GVL health payload and process-local counter tests with explicit runtime opt-in. + +#[cfg(test)] +mod config_funding_support; +#[cfg(test)] +mod fanout_limits; #[cfg(test)] mod tests { + use crate::config_funding_support as config_funding; use auctioneer_core::handlers::health_gvl::{counter, gcl_stale_counter, health_gvl}; - use auctioneer_core::host_primitives::{ - HealthAccessPolicy, TestClockHandle, install_health_primitives, - }; + use auctioneer_core::host_primitives::{TestClockHandle, install_health_counter_scope}; + use auctioneer_core::test_support::config_store::{context, runtime_registry}; use auctioneer_runtime::body_caps::{HEALTH_GVL_CAP_BYTES, RouteMatch, match_route, table}; use auctioneer_runtime::{Clock, FakeClock}; use edgezero_core::body::Body; - use edgezero_core::context::RequestContext; - use edgezero_core::params::PathParams; use futures::executor::block_on; use http::{Method, Request, StatusCode, Uri}; use std::sync::Arc; @@ -26,8 +21,8 @@ mod tests { use time::{Duration, OffsetDateTime}; fn install_acknowledged() { - install_health_primitives(HealthAccessPolicy::UnauthenticatedAcknowledged, "isolate") - .expect("identical reinstalls of the acknowledged policy are idempotent"); + config_funding::install(); + install_health_counter_scope("isolate").unwrap(); } fn generated_at() -> OffsetDateTime { @@ -59,7 +54,11 @@ mod tests { .body(Body::from_bytes(Vec::new())) .unwrap(); request.extensions_mut().insert(TestClockHandle(clock)); - let ctx = RequestContext::new(request, PathParams::default()); + request.extensions_mut().insert(runtime_registry( + &serde_json::json!({"schema_version": 1_u8, + "gvl": {"authenticated_health": {"enabled": false}}}), + )); + let ctx = context(request); let response = health_gvl(ctx).await.expect("handler returns Ok"); let (parts, body) = response.into_parts(); let bytes = body diff --git a/crates/auctioneer-core/tests/health_gvl_auth.rs b/crates/auctioneer-core/tests/health_gvl_auth.rs index a040125..8226a49 100644 --- a/crates/auctioneer-core/tests/health_gvl_auth.rs +++ b/crates/auctioneer-core/tests/health_gvl_auth.rs @@ -1,56 +1,85 @@ -//! Token-policy binary for `/_auctioneer/health/gvl`. -//! -//! `HEALTH_POLICY` is a process-global `OnceLock`, so this binary owns -//! the Token state; ALL auth request shapes share ONE `#[test]` that -//! installs once and exercises the table — three `#[test]`s racing to -//! install would interleave with the conflicting-install case. The -//! token is adapter-resolved and installed as a typed policy; core -//! never reads `std::env`, so the test installs a mock authenticator -//! wrapped in the Token policy. +//! Runtime policy and secret-store authorization for the GVL health route. + +#[cfg(test)] +mod config_funding_support; +#[cfg(test)] +mod fanout_limits; #[cfg(test)] mod tests { + use crate::config_funding_support as config_funding; use auctioneer_core::handlers::health_gvl::health_gvl; - use auctioneer_core::host_primitives::{ - BearerToken, HealthAccessPolicy, HealthAuth, HealthBootError, TestClockHandle, - install_health_primitives, - }; + use auctioneer_core::host_primitives::{BearerToken, TestClockHandle}; + use auctioneer_core::test_support::config_store::{context, runtime_registry}; use auctioneer_runtime::{Clock, FakeClock}; + use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::context::RequestContext; - use edgezero_core::params::PathParams; + use edgezero_core::config_store::BoundedStoreRead; + use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::store_registry::{BoundSecretStore, SecretRegistry, StoreRegistry}; + use edgezero_core::time::{Deadline, MonotonicClock}; use futures::executor::block_on; use http::{HeaderValue, Method, Request, StatusCode, Uri}; - use std::sync::Arc; + use serde_json::{Value, json}; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; - struct FixedToken(&'static str); - - impl HealthAuth for FixedToken { - fn verify(&self, presented: &str) -> bool { - // Test double; production impls compare in constant time. - presented == self.0 + struct TokenStore { + token: Option, + calls: AtomicUsize, + backend_bytes: u64, + } + #[async_trait::async_trait(?Send)] + impl SecretStore for TokenStore { + async fn get_bytes(&self, _: &str, _: &str) -> Result, SecretError> { + panic!("health must use bounded reads"); } - - fn identity(&self) -> [u8; 32] { - let mut id = [0_u8; 32]; - let bytes = self.0.as_bytes(); - let n = bytes.len().min(32); - id[..n].copy_from_slice(&bytes[..n]); - id + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + _: &MonotonicClock, + _: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + assert_eq!((store_name, key), ("custom_secrets", "custom_gvl_token")); + assert_eq!((max_backend_bytes, max_value_bytes), (4096, 4096)); + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(BoundedStoreRead { + backend_bytes: self.backend_bytes, + value: self.token.clone(), + }) } } + fn store(token: Option<&[u8]>, backend_bytes: u64) -> Arc { + Arc::new(TokenStore { + token: token.map(Bytes::copy_from_slice), + calls: AtomicUsize::new(0), + backend_bytes, + }) + } - fn install_token() { - install_health_primitives( - HealthAccessPolicy::Token(Arc::new(FixedToken("t"))), - "process", + async fn status_for(bearer: Option) -> StatusCode { + status_with( + json!({"schema_version": 1_u8, "gvl": {"authenticated_health": { + "enabled": true, "token_secret": "custom_gvl_token"}}}), + Some(store(Some(b"t"), 1)), + bearer, ) - .expect("identical reinstalls of the token policy are idempotent"); + .await } - async fn status_for(bearer: Option) -> StatusCode { + async fn status_with( + policy: Value, + store: Option>, + bearer: Option, + ) -> StatusCode { + config_funding::install(); let generated = OffsetDateTime::parse(auctioneer_config_data::gvl_archive_generated_at(), &Rfc3339) .expect("baked generated_at parses as RFC 3339"); @@ -64,9 +93,26 @@ mod tests { } let mut request = builder.body(Body::from_bytes(Vec::new())).unwrap(); request.extensions_mut().insert(TestClockHandle(clock)); - let ctx = RequestContext::new(request, PathParams::default()); + request.extensions_mut().insert(runtime_registry(&policy)); + if let Some(selected_store) = store { + let shared: Arc = selected_store; + let registry: SecretRegistry = StoreRegistry::single_id( + "selected".to_owned(), + BoundSecretStore::new(SecretHandle::new(shared), "custom_secrets".to_owned()), + ); + request.extensions_mut().insert(registry); + } + let ctx = context(request); let response = health_gvl(ctx).await.expect("handler returns Ok"); let (parts, _body) = response.into_parts(); + assert_eq!( + parts + .headers + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "health responses must not outlive the selected runtime access policy" + ); if parts.status == StatusCode::UNAUTHORIZED { assert_eq!( parts @@ -82,7 +128,6 @@ mod tests { #[test] fn health_gvl_returns_401_when_token_set_and_missing() { - install_token(); // The NBSP case is constructed from raw UTF-8 bytes: a // string-based builder rejects non-visible-ASCII BEFORE the // handler runs, which would test the builder, not the parser. @@ -151,24 +196,79 @@ mod tests { } #[test] - fn conflicting_health_policy_fails_boot() { - install_token(); - let conflicting = install_health_primitives( - HealthAccessPolicy::Token(Arc::new(FixedToken("other"))), - "process", - ); - assert!( - matches!(conflicting, Err(HealthBootError::ConflictingPolicy)), - "a second install with a DIFFERENT token must fail boot" - ); - let idempotent = install_health_primitives( - HealthAccessPolicy::Token(Arc::new(FixedToken("t"))), - "process", - ); - assert!( - idempotent.is_ok(), - "a second install with the SAME secret is idempotent (identity-compared)" - ); + fn health_policy_and_secret_rotation_apply_per_request() { + block_on(async { + let policy = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": { + "enabled": true, "token_secret": "custom_gvl_token"}}}); + for (token, bearer, expected) in [ + (b"first".as_slice(), "Bearer first", StatusCode::OK), + ( + b"second".as_slice(), + "Bearer first", + StatusCode::UNAUTHORIZED, + ), + (b"second".as_slice(), "Bearer second", StatusCode::OK), + ] { + assert_eq!( + status_with( + policy.clone(), + Some(store(Some(token), u64::try_from(token.len()).unwrap())), + Some(HeaderValue::from_static(bearer)) + ) + .await, + expected + ); + } + let unused = store(Some(b"t"), 1); + assert_eq!(status_with(json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": false}}}), + Some(Arc::clone(&unused)), None).await, StatusCode::OK); + assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + assert_eq!( + status_with(policy, None, Some(HeaderValue::from_static("Bearer t"))).await, + StatusCode::UNAUTHORIZED + ); + }); + } + + #[test] + fn invalid_policy_and_secret_faults_never_open_health() { + block_on(async { + for policy in [ + json!({"schema_version": 1_u8}), + json!({"schema_version": 1_u8, "gvl": {}}), + json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": true}}}), + json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": false, "token_secret": "custom_gvl_token"}}}), + ] { + let unused = store(Some(b"t"), 1); + assert_eq!( + status_with( + policy, + Some(Arc::clone(&unused)), + Some(HeaderValue::from_static("Bearer t")) + ) + .await, + StatusCode::UNAUTHORIZED + ); + assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + } + let policy = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": true, "token_secret": "custom_gvl_token"}}}); + for fault in [ + store(None, 0), + store(Some(b""), 0), + store(Some(b"t"), 4097), + store(Some(&vec![b't'; 4097]), 1), + ] { + assert_eq!( + status_with( + policy.clone(), + Some(fault), + Some(HeaderValue::from_static("Bearer t")) + ) + .await, + StatusCode::UNAUTHORIZED + ); + } + }); } #[test] diff --git a/crates/auctioneer-core/tests/health_gvl_docs.rs b/crates/auctioneer-core/tests/health_gvl_docs.rs index 8998646..2346067 100644 --- a/crates/auctioneer-core/tests/health_gvl_docs.rs +++ b/crates/auctioneer-core/tests/health_gvl_docs.rs @@ -14,7 +14,8 @@ mod tests { "## Operational endpoints (`/_auctioneer/*`)", "`GET /_auctioneer/health/gvl`", "fail-closed", - "allow_unauthenticated_health", + "[gvl.authenticated_health]", + "token_secret", "restrict the `/_auctioneer/*` path prefix at the ingress layer", "docs/deployment/restrict-operational-endpoints.md", ] { diff --git a/crates/auctioneer-core/tests/health_gvl_failclosed.rs b/crates/auctioneer-core/tests/health_gvl_failclosed.rs index 4e75b8c..e12f702 100644 --- a/crates/auctioneer-core/tests/health_gvl_failclosed.rs +++ b/crates/auctioneer-core/tests/health_gvl_failclosed.rs @@ -1,11 +1,5 @@ -//! Fail-closed regression: with NO health policy installed the -//! endpoint answers 401. -//! -//! This lives in its OWN test binary precisely because it must observe -//! a FRESH, never-written policy `OnceLock` — calling -//! `install_health_primitives` anywhere in this binary (even for a -//! counter scope) would write the process-global cell and destroy the -//! state under test. +//! Missing runtime policy fails closed even without installed host primitives. +//! Keep this binary isolated from funded/booted handler tests. #[cfg(test)] mod tests { @@ -18,7 +12,7 @@ mod tests { use time::OffsetDateTime; #[test] - fn health_gvl_returns_401_when_no_policy_installed() { + fn health_gvl_returns_401_when_no_runtime_policy_bound() { block_on(async { for bearer in [None, Some("Bearer sometoken")] { let mut builder = Request::builder() diff --git a/crates/auctioneer-core/tests/serialize_allocation_bound.rs b/crates/auctioneer-core/tests/serialize_allocation_bound.rs index b6c857c..5402115 100644 --- a/crates/auctioneer-core/tests/serialize_allocation_bound.rs +++ b/crates/auctioneer-core/tests/serialize_allocation_bound.rs @@ -140,8 +140,14 @@ mod alloc_track { #[global_allocator] static COUNTING_ALLOC: alloc_track::CountingAllocator = alloc_track::CountingAllocator; +#[cfg(test)] +mod config_funding_support; +#[cfg(test)] +mod fanout_limits; + #[cfg(test)] mod tests { + use crate::config_funding_support as config_funding; use std::{hint::black_box, thread}; use super::alloc_track::{ @@ -149,15 +155,13 @@ mod tests { track_reallocations_at_least_on_this_thread, }; use auctioneer_core::handlers::health_gvl::health_gvl; - use auctioneer_core::host_primitives::{ - HealthAccessPolicy, TestClockHandle, install_health_primitives, - }; + use auctioneer_core::host_primitives::{TestClockHandle, install_health_counter_scope}; use auctioneer_core::serialize::{SerializeError, serialize_response_within}; + use auctioneer_core::test_support::config_store::{context, runtime_registry}; use auctioneer_openrtb::types::bid_response::BidResponse; use auctioneer_runtime::FakeClock; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; - use edgezero_core::params::PathParams; use futures::executor::block_on; use http::{Request, StatusCode}; use std::sync::Arc; @@ -336,7 +340,11 @@ mod tests { .insert(TestClockHandle(Arc::new(FakeClock::new( OffsetDateTime::UNIX_EPOCH, )))); - RequestContext::new(request, PathParams::default()) + request.extensions_mut().insert(runtime_registry( + &serde_json::json!({"schema_version": 1_u8, + "gvl": {"authenticated_health": {"enabled": false}}}), + )); + context(request) } #[test] @@ -344,8 +352,8 @@ mod tests { thread::spawn(|| { let scope: &'static str = Box::leak("isolate\0\n\"\\\u{00e9}".repeat(4097).into_boxed_str()); - install_health_primitives(HealthAccessPolicy::UnauthenticatedAcknowledged, scope) - .unwrap(); + config_funding::install(); + install_health_counter_scope(scope).unwrap(); let warmup = block_on(health_gvl(health_context())).unwrap(); assert_eq!(warmup.status(), StatusCode::OK); assert_eq!(warmup.headers()["content-type"], "application/json"); diff --git a/docs/architecture.md b/docs/architecture.md index c01d99f..ffca9f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -450,13 +450,19 @@ operator runtime config The authoring file [`auctioneer.toml`](../auctioneer.toml) is the direct payload: required root `schema_version = 1`, `host`, `account_defaults`, `accounts`, -`bidders`, `features` and `floor_currency`. There are no baked/startup/runtime +`bidders`, `features`, `gvl` and `floor_currency`. There are no baked/startup/runtime root wrappers, source modes or backward wire aliases. Builds never read operator policy. The SDK trait wrapper preserves model and wire layout. Accounts are carried in that runtime blob, not fetched from separate account records. Runtime-field environment overrides are applied at push time to keys already present in the source; -they are not a live, per-instance policy override channel. Boot health -credentials are a separate provider-specific input. +they are not a live, per-instance policy override channel. GVL health access is +part of that runtime policy: `gvl.authenticated_health` stays closed when omitted, +requires a default Secret Store entry reference when enabled, or permits anonymous +access when explicitly disabled with no reference. The shared handler resolves +policy and the selected secret on every health request; adapters install only the +immutable counter scope. Config and secret visibility follow the existing SDK +provider lifecycle, including Axum's startup file snapshot. Health credentials use +the same request-bound default secret provider as other credentials. Host/adapter facts, store/trust bindings and installed limits retain their existing owners; they are not another Auctioneer startup config. Runtime policy @@ -687,7 +693,8 @@ This describes technical enforcement, not legal certification. `/status` is not proof of bidder reachability, usable consent data, or production certification. The internal health route is fail-closed without -an installed access policy. Tokenless opt-in requires an ingress path +valid `gvl.authenticated_health` runtime policy and, when authentication is enabled, +a valid credential. Explicit anonymous access requires an ingress path restriction; see [internal-route deployment guidance](deployment/restrict-operational-endpoints.md). The sync routes use the auxiliary admission class and a validated runtime diff --git a/docs/deployment/restrict-operational-endpoints.md b/docs/deployment/restrict-operational-endpoints.md index 8b7b49c..78ca029 100644 --- a/docs/deployment/restrict-operational-endpoints.md +++ b/docs/deployment/restrict-operational-endpoints.md @@ -1,12 +1,28 @@ # Restricting the `/_auctioneer/*` prefix at the ingress auctioneer serves operational endpoints under the `/_auctioneer/*` prefix -(`/_auctioneer/health/gvl` today). The handler itself is fail-closed — it -answers 401 unless the adapter boot installed an explicit -`HealthAccessPolicy` — so ingress restriction is the SECOND layer. -It is required for any deployment that installs the -`UnauthenticatedAcknowledged` policy (`allow_unauthenticated_health = -true`), and recommended defence-in-depth everywhere else. +(`/_auctioneer/health/gvl` today). The shared handler resolves +`gvl.authenticated_health` from the pushed operator policy on every health +request. An omitted section keeps health closed. `enabled = true` requires a +`token_secret` entry name in the default Secret Store. Missing/malformed config, +secret faults, empty secrets, secrets over 4096 bytes and failed authorization +return 401 with a Bearer challenge. Reads use the existing config-fetch deadline; +provider APIs may materialize a complete secret before the accepted-size check, +and synchronous provider calls cannot be preempted by a cooperative deadline. +`/status` remains independent. + +To allow anonymous access, explicitly configure: + +```toml +[gvl.authenticated_health] +enabled = false +``` + +This mode must omit `token_secret` and requires an ingress restriction for the +prefix. Ingress restriction is recommended defence in depth for authenticated +health as well. Adapters install counter scope only; provider flags and boot +credentials do not determine access. See the +[secret provisioning mappings](../../README.md#operational-endpoints-_auctioneer). Path restriction is an **L7 concern**: the rule must match the HTTP path. An L3/L4 mechanism (IP + port), such as a Kubernetes @@ -41,8 +57,8 @@ Attach VCL (or the equivalent Compute request guard) that rejects unauthorised requests to the prefix before they reach the service: A presence check is **not** authentication: `Authorization: junk` -satisfies `if (!req.http.Authorization)` and nothing downstream -re-checks it when `allow_unauthenticated_health = true`. The value has +satisfies `if (!req.http.Authorization)` and the shared handler permits +anonymous access when `gvl.authenticated_health.enabled = false`. The value has to be VALIDATED, and the comparison has to be constant-time so the token cannot be recovered byte by byte from response timing. @@ -75,10 +91,10 @@ if (req.url.path ~ "^/_auctioneer/") { } ``` -The token this validates is the SAME credential the handler's -`HealthAccessPolicy` checks — `health_gvl_token` in the Fastly secret -store. Keeping the two in step means a rotation that misses one layer -fails closed rather than open. +For authenticated health, use the same credential selected by +`gvl.authenticated_health.token_secret` in the linked default Fastly secret +store (for example `health_gvl_token`). Keeping the two in step means a +rotation that misses one layer fails closed rather than open. Alternatively, and preferably where it is available, scope the service behind a signed-request policy or Fastly's own edge authentication for diff --git a/docs/superpowers/plans/2026-11-10-gvl-health-policy.md b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md new file mode 100644 index 0000000..5a2a859 --- /dev/null +++ b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md @@ -0,0 +1,30 @@ +# GVL health policy implementation plan + +> **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development for these steps. Preserve the existing author worktree and serialize Cargo jobs. + +**Goal:** Configure GVL health authentication and its secret reference through the shared runtime policy on every adapter. + +**Architecture:** Add strict optional `gvl.authenticated_health` configuration. Resolve and authorize only in the shared health handler through bounded SDK stores; adapters install counter scope only. + +**Tech stack:** Rust, Serde, validator, EdgeZero typed extraction and bounded stores; existing native and guest test owners. + +## 1. Shared model and handler + +- [x] Add failing inline schema tests to `crates/auctioneer-config/src/policy.rs` for the documented nested shape, strict null/unknown/type/required rules and bounded secret references. Run `cargo test --locked --offline -j2 -p auctioneer-config --features config-model --lib policy::tests::gvl` and observe the absent-field failure. +- [x] Add the optional root GVL policy, with separately validated authenticated-health settings. Preserve omission in serialization and existing neutral config fixtures. +- [x] Replace boot authorization in `crates/auctioneer-core/src/handlers/health_gvl.rs` with existing runtime resolution and bounded default-secret reads. Retain 401/challenge and SP-only Bearer parsing. Keep secret loading out of SDK eager field metadata. +- [x] Replace global health-policy state in `crates/auctioneer-core/src/host_primitives/registry.rs` with `install_health_counter_scope`; retain constant-time `BearerToken` verification. Update exports and existing test owners. +- [x] Extend `crates/auctioneer-core/tests/health_gvl_auth.rs` for config/secret selection, dynamic rotation, denied faults, and unchanged header controls. Adapt payload/observer/allocation test setup to real typed config and funded boot. + +## 2. Adapter migration + +- [x] Replace boot policy readers in Fastly, Cloudflare, Spin and Axum (including baseline probe) with counter-scope installation. Remove obsolete provider flags and update their owning health/manifest tests to verify no provider policy authority and shared runtime behavior. +- [x] Preserve SDK dispatch/logger/envelope order and store wiring. `fastly.toml` keeps `auctioneer_config = ''`; secrets remain env-backed. Cloudflare secret names and Spin variable mapping must match the selected default store entry after the SDK's lowercase conversion. +- [x] Run affected adapter tests and strict test Clippy, serialized with core jobs. Compile all changed guest targets and execute Fastly guest controls when the requisite artifacts are built. + +## 3. Operator documentation and verification + +- [x] Add the secure reference section to `auctioneer.toml`; update `README.md`, `docs/deployment/restrict-operational-endpoints.md`, active config inventory and docs tests. Leave historical records intact. +- [x] Run config/core health suites, runtime-config failure/funding contracts, CLI config contracts, adapters, formatting and diff checks. Execute real Fastly/Viceroy health requests with synthetic typed config and a custom secret name; record unavailable checks honestly. +- [x] Review final spec compliance and implementation correctness independently, fix findings, and repeat only impacted checks. +- [x] Commit the focused change and push the already-authorized PR branch; update PR 160 description with accurate scope and evidence. Do not deploy or merge. diff --git a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md index e384185..2b8429c 100644 --- a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md +++ b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md @@ -198,8 +198,8 @@ Sources: [limit and target types](../../../crates/auctioneer-runtime/src/budget. | Cloudflare Worker vars/secrets and KV binding names | `S` provider translation and `V` secrets; use declared binding names, not a fictional process environment or enumerable binding map. | | Fastly linked config/secret stores and logging hook | `S` provider resource links/logical aliases and `V` secrets. Key/location restrictions must match the serving SDK path; a generic selector that only works on Axum is not sufficient. | | Hosted Spin variables/KV labels and logging level | `S` hosted-provider translation and `V` secrets; distinguish hosted deployment facts from generic Spin local execution. | -| `AUCTIONEER__RUNTIME__ALLOW_UNAUTHENTICATED_HEALTH` / provider mapping | `S` health-only authorization intent, distinct from fatal domain settings. Malformed input never opens health or logs its raw value. Preserve Axum-fatal versus edge-Closed resolution; installation conflicts remain fatal. | -| `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` / provider secret mapping | `V`; current platform reads directly materialize values outside the runtime extractor. #128/#130 and provider ports must establish accepted-size, materialization and applicable time bounds. Core does not read provider environment values. Current health policy is frozen, not freely rotating per request. | +| `gvl.authenticated_health.{enabled,token_secret}` | `P` health-only authorization. Omitted section stays closed; enabled requires a valid default-store secret entry reference; disabled explicitly permits anonymous access and must omit the reference. Resolve through existing funded runtime config on each health request. No adapter policy flag remains. | +| Configured GVL health secret entry | `V` via the request's existing default Secret Store binding, maximum accepted payload 4096 bytes and existing config-fetch deadline. Fresh lookup per health request; empty/missing/faulted/excessive secrets deny access. Providers may materialize the complete value before the size check; synchronous provider calls have cooperative deadline checks. Axum uses SDK `EDGEZERO__SECRETS__` with ASCII alphanumeric/underscore entry names; Cloudflare uses the literal entry name; Fastly uses the captured linked default alias; Spin lowercases the entry and needs declaration plus component mapping. The local Fastly `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` is provisioning only. Config visibility follows each provider's lifecycle, including Axum's startup file snapshot; secrets rotate through existing provider visibility. | | `usersync_cookie_keyring` | `V`, one existing bounded secret read at the route's UID stage; preserve active/previous key rotation and deadline/cancellation behavior. | | Bidder credential references and coherent bundles | Permitted typed references through `P` within host-approved bindings; secret values `V` through the existing post-privacy/schema bounded reader. No raw secrets in output policy. | | SDK logging settings and host service implementations | `S`; establish the logging sink before diagnostics and keep startup materialization/residency accounted. | @@ -216,7 +216,7 @@ isolation just because the policy overlay was disabled. Sources: [CLI application](../../../crates/auctioneer-cli/src/application.rs), [zero-layout SDK trait boundary](../../../crates/auctioneer-core/src/config/sdk.rs), [bounded cookie-key reader](../../../crates/auctioneer-core/src/handlers/usersync_keys.rs), -[health policy](../../../crates/auctioneer-core/src/host_primitives/registry.rs), +[health policy](../../../crates/auctioneer-core/src/handlers/health_gvl.rs), [logical declarations](../../../edgezero.toml), four adapter entrypoints and their provider manifests. diff --git a/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md new file mode 100644 index 0000000..48d9a4b --- /dev/null +++ b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md @@ -0,0 +1,61 @@ +# GVL health policy in operator configuration + +The operator owns GVL health authentication in `auctioneer.toml`: + +```toml +[gvl.authenticated_health] +enabled = true +token_secret = "health_gvl_token" +``` + +`enabled` is required when this section is present. Authentication enabled +requires a nonempty secret entry name, at most 128 UTF-8 bytes, without ASCII +controls or leading/trailing whitespace. It is a reference in the declared +default Secret Store, never token bytes. Unknown fields and explicit nulls +are rejected. An omitted section stays closed. `enabled = false` explicitly +permits anonymous access and must omit `token_secret` to avoid conflicting +intent. No adapter-specific flag or environment alias remains authoritative. + +The shared health handler uses the existing funded, bounded runtime-config +resolver. It drops the complete policy before fetching the selected secret +through the request's default secret binding. It accepts at most 4096 bytes, +within the existing auxiliary signing allowance, with the fixed config-fetch +deadline and a post-read deadline/cap check. Provider APIs may materialize the +whole value before enforcing this accepted-size limit; synchronous provider +deadline checks are cooperative. Empty, missing, excessive or +unavailable secrets deny access. Secret bytes are hashed for constant-time +comparison and never retained globally or exposed through diagnostics. +Missing/malformed config also denies access; `/status` remains the independent +liveness and config-fault diagnostic. HTTP denials retain 401 and the Bearer +challenge. Health replies carry `Cache-Control: no-store` across authorization +and freshness outcomes. Existing header parsing and health payload semantics remain. + +Adapters install only their immutable counter scope: process, request, isolate +or instance. Each health request resolves policy and token through the provider's +existing visibility lifecycle (Axum's file config remains a startup snapshot); +ordinary auctions, status and bidder-info reads do not fetch health secrets. +The obsolete global boot health policy and provider flag readers are removed. +Local manifests retain only provider secret wiring, with no inline credentials; +Fastly's `auctioneer_config` entry stays empty until CLI push. + +Axum's SDK maps `health_gvl_token` to `EDGEZERO__SECRETS__HEALTH_GVL_TOKEN` +and accepts ASCII alphanumeric/underscore entry names. Cloudflare uses the +entry name literally as a Worker secret. Spin lowercases the entry name and +requires a declared, mapped component variable. Fastly uses the entry name +in the captured default Secret Store. Provider naming restrictions still apply. + +Validation covers strict TOML/JSON round trips, required/contradictory fields, +all authorization-header controls, runtime config updates and secret rotation, +renamed default bindings, missing/malformed config and secret faults, bounded +and cancelled reads, and independent status/auction availability. Adapter +contracts and a real Fastly/Viceroy request verify shared SDK binding behavior. +Existing resource ceilings and historical evidence are unchanged. + +The pinned native build measures owner storage at 19,256 bytes per auction, +up from the separately retained 19,160-byte baseline. The 96-byte increase is +in the compiler-owned auction execution frame holding the expanded policy; +prepared state, failure and ledger layouts are unchanged. Active native +accounting controls add this measured delta to demand and subtract it from +headroom. They retain every workload, ceiling and historical capture. Guest +execution is checked separately; this native measurement is not a guest-size +or memory-fit qualification. diff --git a/edgezero.toml b/edgezero.toml index bfc2c1e..e33e97d 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -19,8 +19,8 @@ entry = "crates/auctioneer-core" # # Privacy reference-data health: # /_auctioneer/health/gvl (GET) operator-only; fail-closed 401 unless -# the adapter boot installed an access -# policy — restrict /_auctioneer/* at the +# operator policy grants access through +# gvl.authenticated_health; restrict /_auctioneer/* at the # ingress (docs/deployment/restrict-operational-endpoints.md) # # User-sync routes: diff --git a/fastly.toml b/fastly.toml index 4f4b4a2..6c85a7d 100644 --- a/fastly.toml +++ b/fastly.toml @@ -16,12 +16,10 @@ format = "inline-toml" # Populate locally with: auctioneer-cli config push --adapter fastly --local. auctioneer_config = '' -# Health remains closed unless a local development token is supplied. -allow_unauthenticated_health = "false" - [[local_server.secret_stores.auctioneer_secrets]] key = "health_gvl_token" # Viceroy uses an empty value when this host environment variable is unset. +# Local provisioning only; runtime authorization reads the configured secret entry. # Supply a development-only token when running `fastly compute serve`. env = "AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN" From 7142df7b526eec5b8a99dd5968ca88929ba26f21 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:54:58 -0800 Subject: [PATCH 13/14] Nest GVL health settings under host --- README.md | 29 ++-- auctioneer.toml | 20 +-- .../tests/resource_profile.rs | 14 +- .../src/health.rs | 12 +- .../wrangler.toml | 2 +- .../tests/manifest_health_stores.rs | 16 +- crates/auctioneer-adapter-spin/spin.toml | 8 +- crates/auctioneer-adapter-spin/src/health.rs | 12 +- crates/auctioneer-cli/src/application.rs | 7 +- crates/auctioneer-config/src/policy.rs | 142 ++++++++---------- crates/auctioneer-config/tests/root_shape.rs | 28 ++-- .../src/handlers/health_gvl.rs | 11 +- crates/auctioneer-core/tests/health_gvl.rs | 2 +- .../auctioneer-core/tests/health_gvl_auth.rs | 43 ++++-- .../auctioneer-core/tests/health_gvl_docs.rs | 5 +- .../tests/serialize_allocation_bound.rs | 2 +- docs/architecture.md | 17 ++- .../restrict-operational-endpoints.md | 27 ++-- .../plans/2026-11-10-gvl-health-policy.md | 14 +- ...perator-runtime-configuration-inventory.md | 2 +- .../2026-11-10-gvl-health-policy-design.md | 31 ++-- edgezero.toml | 2 +- 22 files changed, 251 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 0562859..c831c7b 100644 --- a/README.md +++ b/README.md @@ -287,18 +287,25 @@ bundled vendor-list versions, and the rolling unavailable-lookup counter). Configure access in the pushed operator policy: ```toml -[gvl.authenticated_health] -enabled = true -token_secret = "health_gvl_token" +[host.gvl] +authenticate_health = true +authenticate_health_token_secret = "health_gvl_token" ``` -The handler is fail-closed. `token_secret` names an entry in the declared -default Secret Store. Omit the section to keep health closed. To permit anonymous access, set `enabled = false` -and omit `token_secret`. The shared handler resolves config and the selected -secret on each health request; updates take effect with the provider's existing +The handler is fail-closed. `authenticate_health_token_secret` names an entry in +the declared default Secret Store. Omit the section or `authenticate_health` to +keep health closed. `authenticate_health = true` requires a nonempty reference; +an empty reference is invalid. To permit anonymous access, set +`authenticate_health = false`; the reference may be absent, empty or a valid entry +name, and the handler never reads it. All nonempty references must be at most 128 +UTF-8 bytes, without ASCII controls or surrounding whitespace. A reference without +a mode, unknown fields and explicit nulls are rejected. The shared handler resolves +config and, for authenticated health, the selected secret on each health request; +updates take effect with the provider's existing config/secret visibility lifecycle. Axum's file-backed config keeps its startup -snapshot until restart. Missing or invalid policy, unavailable/empty secrets, -and secrets larger than 4096 bytes return 401 with a Bearer challenge. `/status` +snapshot until restart. Missing or invalid policy returns 401 with a Bearer +challenge. In authenticated mode, unavailable/empty secrets and secrets larger +than 4096 bytes also return 401. `/status` remains independent. Ordinary routes do not fetch health credentials. Provision `health_gvl_token` through the existing SDK secret binding: @@ -310,8 +317,8 @@ Provision `health_gvl_token` through the existing SDK secret binding: The local manifest's `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` is only a Viceroy provisioning source; the runtime does not read that environment variable. - Spin: a secret application variable declared and mapped into the component. - The SDK lowercases entry names; a different `token_secret` needs its own - matching declaration and component mapping. + The SDK lowercases entry names; a different `authenticate_health_token_secret` + needs its own matching declaration and component mapping. Deployments that opt in to serving `/_auctioneer/*` without a token MUST restrict the `/_auctioneer/*` path prefix at the ingress layer. That diff --git a/auctioneer.toml b/auctioneer.toml index 2cf55d8..e4c0b9d 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -28,19 +28,21 @@ schema_version = 1 -# Omit this section to keep GVL health closed. The value is a secret entry name -# in the declared default Secret Store, never token bytes. Names are 1..128 UTF-8 -# bytes, without ASCII controls or surrounding whitespace. enabled is required. -# Anonymous access requires enabled = false and omission of token_secret; restrict -# /_auctioneer/* at ingress when choosing that mode. -[gvl.authenticated_health] -enabled = true -token_secret = "health_gvl_token" - [host] # Plain-text response used by the status handler. status_response = "auctioneer local" +# Omit this section or authenticate_health to keep GVL health closed. The secret +# reference names an entry in the declared default Secret Store, never token bytes. +# Nonempty names are 1..128 UTF-8 bytes, without ASCII controls or surrounding +# whitespace. authenticate_health = true requires a nonempty reference. +# authenticate_health = false permits an absent, empty or valid named reference +# and never reads the secret; restrict /_auctioneer/* at ingress in that mode. +# A reference without authenticate_health, unknown fields and explicit nulls fail. +[host.gvl] +authenticate_health = true +authenticate_health_token_secret = "health_gvl_token" + # Requested limits can lower, but never expand, independently installed capacity. # Durations are milliseconds, sizes are bytes, and counts are integers. [host.resource_limits] diff --git a/crates/auctioneer-adapter-axum/tests/resource_profile.rs b/crates/auctioneer-adapter-axum/tests/resource_profile.rs index b8fef6d..3a04311 100644 --- a/crates/auctioneer-adapter-axum/tests/resource_profile.rs +++ b/crates/auctioneer-adapter-axum/tests/resource_profile.rs @@ -948,22 +948,26 @@ mod tests { const HEALTH: &str = "/_auctioneer/health/gvl"; for (policy, sdk_token, expected) in [ (None, Some(TOKEN), 401_u16), - (Some(json!({"enabled": false})), None, 200_u16), + (Some(json!({"authenticate_health": false})), None, 200_u16), ( - Some(json!({"enabled": true, "token_secret": "health_gvl_token"})), + Some( + json!({"authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token"}), + ), Some(TOKEN), 200_u16, ), ( - Some(json!({"enabled": true, "token_secret": "health_gvl_token"})), + Some( + json!({"authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token"}), + ), None, 401_u16, ), ] { let fixture = Fixture::new(); - let mut data = json!({"schema_version": 1_u8}); + let mut data = json!({"schema_version": 1_u8, "host": {}}); if let Some(configured_policy) = policy { - data["gvl"] = json!({"authenticated_health": configured_policy}); + data["host"]["gvl"] = configured_policy; } let envelope = BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned()); let config_id = AuctioneerApp::stores().config.unwrap().default; diff --git a/crates/auctioneer-adapter-cloudflare/src/health.rs b/crates/auctioneer-adapter-cloudflare/src/health.rs index d085a35..53925eb 100644 --- a/crates/auctioneer-adapter-cloudflare/src/health.rs +++ b/crates/auctioneer-adapter-cloudflare/src/health.rs @@ -49,16 +49,16 @@ mod tests { const TOKEN: &str = "health-token-must-not-appear-in-logs"; fn config_envelope(case: &str) -> String { - let mut data = json!({"schema_version": 1_u8}); + let mut data = json!({"schema_version": 1_u8, "host": {}}); match case { - "anonymous" => data["gvl"] = json!({"authenticated_health": {"enabled": false}}), + "anonymous" => data["host"]["gvl"] = json!({"authenticate_health": false}), "missing-secret" => { - data["gvl"] = json!({"authenticated_health": { - "enabled": true, "token_secret": "health_gvl_token" - }}); + data["host"]["gvl"] = json!({ + "authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token" + }); } "malformed-config" => { - data["gvl"] = json!({"authenticated_health": {"enabled": "true"}}); + data["host"]["gvl"] = json!({"authenticate_health": "true"}); } _ => {} } diff --git a/crates/auctioneer-adapter-cloudflare/wrangler.toml b/crates/auctioneer-adapter-cloudflare/wrangler.toml index a90a299..b50f73b 100644 --- a/crates/auctioneer-adapter-cloudflare/wrangler.toml +++ b/crates/auctioneer-adapter-cloudflare/wrangler.toml @@ -17,7 +17,7 @@ EDGEZERO__ADAPTER__PORT = "" # Optional credential, never a plaintext [vars] default: # wrangler secret put health_gvl_token -# Select this literal entry with gvl.authenticated_health.token_secret in +# Select this literal entry with host.gvl.authenticate_health_token_secret in # auctioneer.toml. Health stays closed when the section is omitted. # Cookie signing uses the existing default logical secret-store binding. diff --git a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs index 0a4fb0e..a8eb168 100644 --- a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs +++ b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs @@ -113,23 +113,23 @@ exit 71 let config_id = stores.config.unwrap().default; let secrets_id = stores.secrets.unwrap().default; let mut fixture = manifest(); - let mut data = json!({"schema_version": 1_u8}); + let mut data = json!({"schema_version": 1_u8, "host": {}}); if case != "omitted" { - data["gvl"] = json!({"authenticated_health": { - "enabled": case != "anonymous", - "token_secret": if case == "renamed-entry" { "renamed_health_token" } else { "health_gvl_token" }, - }}); + data["host"]["gvl"] = json!({ + "authenticate_health": case != "anonymous", + "authenticate_health_token_secret": if case == "renamed-entry" { "renamed_health_token" } else { "health_gvl_token" }, + }); if case == "anonymous" { assert!( - data["gvl"]["authenticated_health"] + data["host"]["gvl"] .as_object_mut() .unwrap() - .remove("token_secret") + .remove("authenticate_health_token_secret") .is_some() ); } if case == "malformed" { - data["gvl"]["authenticated_health"]["enabled"] = json!("true"); + data["host"]["gvl"]["authenticate_health"] = json!("true"); } } let blob = BlobEnvelope::new(data, "1970-01-01T00:00:00Z".to_owned()); diff --git a/crates/auctioneer-adapter-spin/spin.toml b/crates/auctioneer-adapter-spin/spin.toml index 6c69f17..f066934 100644 --- a/crates/auctioneer-adapter-spin/spin.toml +++ b/crates/auctioneer-adapter-spin/spin.toml @@ -5,10 +5,10 @@ name = "auctioneer-adapter-spin" version = "0.1.0" # SDK secret reads need both the application declaration and component mapping. -# Select health_gvl_token via gvl.authenticated_health.token_secret in the pushed -# operator policy. Different entry names need their own secret declaration and -# component mapping; the SDK lowercases names before variables::get. -# Empty secret defaults are unusable and keep health closed. Provision real +# Select health_gvl_token via host.gvl.authenticate_health_token_secret in the +# pushed operator policy. Different entry names need their own secret declaration +# and component mapping; the SDK lowercases names before variables::get. +# Empty secret defaults cannot authenticate health. Provision real # credentials through a variable provider. [variables] health_gvl_token = { required = false, default = "", secret = true } diff --git a/crates/auctioneer-adapter-spin/src/health.rs b/crates/auctioneer-adapter-spin/src/health.rs index 8dcb8be..d215837 100644 --- a/crates/auctioneer-adapter-spin/src/health.rs +++ b/crates/auctioneer-adapter-spin/src/health.rs @@ -49,16 +49,16 @@ mod tests { const TOKEN: &str = "health-token-must-not-appear-in-logs"; fn config_envelope(case: &str) -> String { - let mut data = json!({"schema_version": 1_u8}); + let mut data = json!({"schema_version": 1_u8, "host": {}}); match case { - "anonymous" => data["gvl"] = json!({"authenticated_health": {"enabled": false}}), + "anonymous" => data["host"]["gvl"] = json!({"authenticate_health": false}), "missing-secret" => { - data["gvl"] = json!({"authenticated_health": { - "enabled": true, "token_secret": "health_gvl_token" - }}); + data["host"]["gvl"] = json!({ + "authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token" + }); } "malformed-config" => { - data["gvl"] = json!({"authenticated_health": {"enabled": "true"}}); + data["host"]["gvl"] = json!({"authenticate_health": "true"}); } _ => {} } diff --git a/crates/auctioneer-cli/src/application.rs b/crates/auctioneer-cli/src/application.rs index 1b7d3bd..a5469cd 100644 --- a/crates/auctioneer-cli/src/application.rs +++ b/crates/auctioneer-cli/src/application.rs @@ -184,6 +184,7 @@ mod config_contract { "host_cookie", "user_sync", "aliases", + "gvl", ], ), ( @@ -344,8 +345,10 @@ mod config_contract { ], ), ("features", &["gzip_outbound", "returnallbidstatus_default"]), - ("gvl", &["authenticated_health"]), - ("gvl.authenticated_health", &["enabled", "token_secret"]), + ( + "host.gvl", + &["authenticate_health", "authenticate_health_token_secret"], + ), ]; #[test] diff --git a/crates/auctioneer-config/src/policy.rs b/crates/auctioneer-config/src/policy.rs index 6349a37..65d2bfa 100644 --- a/crates/auctioneer-config/src/policy.rs +++ b/crates/auctioneer-config/src/policy.rs @@ -48,9 +48,7 @@ impl<'de> Deserialize<'de> for ApplicationSchemaVersion { } } -pub use object_models::{ - AccountPolicy, AuthenticatedHealthPolicy, GvlPolicy, HostPolicy, OperatorPolicy, -}; +pub use object_models::{AccountPolicy, GvlPolicy, HostPolicy, OperatorPolicy}; #[expect( clippy::same_name_method, @@ -99,17 +97,10 @@ mod object_models { skip_serializing_if = "Option::is_none" )] pub floor_currency: Option, - /// GVL diagnostics access policy; secret values remain in the default Secret Store. - #[serde( - default, - deserialize_with = "deserialize_optional_value", - skip_serializing_if = "Option::is_none" - )] - pub gvl: Option, } /// GVL-specific runtime policy. Omitted health settings keep the endpoint closed. - #[derive(Debug, Default, Deserialize, Serialize, Validate)] + #[derive(Debug, Default, Deserialize, Serialize)] #[serde(remote = "Self", deny_unknown_fields)] pub struct GvlPolicy { #[serde( @@ -117,23 +108,14 @@ mod object_models { deserialize_with = "deserialize_optional_value", skip_serializing_if = "Option::is_none" )] - #[validate(nested)] - pub authenticated_health: Option, - } - - /// Explicit GVL health access mode and optional secret entry reference. - #[derive(Debug, Deserialize, Serialize)] - #[serde(remote = "Self", deny_unknown_fields)] - pub struct AuthenticatedHealthPolicy { - /// True requires a token; false deliberately permits anonymous health requests. - pub enabled: bool, + pub authenticate_health: Option, /// Entry in the declared default Secret Store. Required only when enabled. #[serde( default, deserialize_with = "deserialize_optional_value", skip_serializing_if = "Option::is_none" )] - pub token_secret: Option, + pub authenticate_health_token_secret: Option, } /// Optional host policy requests over product defaults. @@ -184,6 +166,14 @@ mod object_models { skip_serializing_if = "Option::is_none" )] pub aliases: Option>, + /// Service-wide GVL diagnostics access; secrets remain in the default Secret Store. + #[serde( + default, + deserialize_with = "deserialize_optional_value", + skip_serializing_if = "Option::is_none" + )] + #[validate(nested)] + pub gvl: Option, } /// The same strict overlay type is used for account defaults and named accounts. @@ -272,7 +262,6 @@ impl Default for OperatorPolicy { bidders: BTreeMap::new(), features: FeatureFlags::default(), floor_currency: None, - gvl: None, } } } @@ -314,9 +303,6 @@ impl Validate for OperatorPolicy { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); errors.merge_self("host", validate_host_policy(&self.host)); - if let Some(gvl) = &self.gvl { - errors.merge_self("gvl", gvl.validate()); - } errors.merge_self( "account_defaults", validate_account_policy(&self.account_defaults), @@ -364,40 +350,23 @@ impl Serialize for GvlPolicy { } } -impl<'de> ObjectFields<'de> for AuthenticatedHealthPolicy { - fn deserialize_fields>(deserializer: D) -> Result { - Self::deserialize(deserializer) - } -} - -impl<'de> Deserialize<'de> for AuthenticatedHealthPolicy { - fn deserialize>(deserializer: D) -> Result { - deserialize_fields(deserializer) - } -} - -impl Serialize for AuthenticatedHealthPolicy { - fn serialize(&self, serializer: S) -> Result { - Self::serialize(self, serializer) - } -} - -impl Validate for AuthenticatedHealthPolicy { +impl Validate for GvlPolicy { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); - let valid = match (self.enabled, self.token_secret.as_deref()) { - (true, Some(reference)) => { - !reference.is_empty() - && reference.len() <= 128 - && reference.trim() == reference - && !reference.bytes().any(|byte| byte.is_ascii_control()) - } - (false, None) => true, - (true, None) | (false, Some(_)) => false, + let reference = self.authenticate_health_token_secret.as_deref(); + let valid_reference = reference.is_none_or(|value| { + value.len() <= 128 + && value.trim() == value + && !value.bytes().any(|byte| byte.is_ascii_control()) + }); + let valid_mode = match (self.authenticate_health, reference) { + (Some(true), Some(value)) => !value.is_empty(), + (Some(false), _) | (None, None) => true, + (Some(true), None) | (None, Some(_)) => false, }; - if !valid { + if !valid_reference || !valid_mode { errors.add( - "token_secret", + "authenticate_health_token_secret", ValidationError::new("invalid_health_token_reference"), ); } @@ -654,27 +623,32 @@ mod tests { use validator::Validate as _; #[test] - fn gvl_authenticated_health_round_trips_both_explicit_modes() { + fn gvl_authenticate_health_round_trips_explicit_modes_and_empty_disabled_secret() { for health in [ - json!({"enabled": true, "token_secret": "health_gvl_token"}), - json!({"enabled": false}), + json!({"authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token"}), + json!({"authenticate_health": false}), + json!({"authenticate_health": false, "authenticate_health_token_secret": ""}), + json!({"authenticate_health": false, "authenticate_health_token_secret": "health_gvl_token"}), + json!({}), ] { - let input = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": health}}); + let input = json!({"schema_version": 1_u8, "host": {"gvl": health}}); let policy: OperatorPolicy = serde_json::from_value(input.clone()).unwrap(); policy.validate().unwrap(); let output = serde_json::to_value(&policy).unwrap(); - assert_eq!(output["gvl"], input["gvl"]); + assert_eq!(output["host"]["gvl"], input["host"]["gvl"]); let restored: OperatorPolicy = toml::from_str(&toml::to_string(&policy).unwrap()).unwrap(); assert_eq!(serde_json::to_value(restored).unwrap(), output); } let policy: OperatorPolicy = serde_json::from_value(json!({"schema_version": 1_u8})).unwrap(); - assert!(serde_json::to_value(policy).unwrap().get("gvl").is_none()); + let output = serde_json::to_value(policy).unwrap(); + assert!(output["host"].get("gvl").is_none()); + assert!(output.get("gvl").is_none()); } #[test] - fn gvl_authenticated_health_rejects_incomplete_conflicting_and_invalid_shapes() { + fn gvl_authenticate_health_rejects_incomplete_and_invalid_shapes() { for gvl in [ json!(null), json!([]), @@ -682,33 +656,45 @@ mod tests { json!({"authenticated_health": null}), json!({"authenticated_health": []}), json!({"authenticated_health": {}}), - json!({"authenticated_health": {"enabled": "true", "token_secret": "token"}}), - json!({"authenticated_health": {"enabled": null}}), - json!({"authenticated_health": {"enabled": false, "unknown": true}}), - json!({"authenticated_health": {"enabled": false, "token_secret": null}}), + json!({"token_secret": "token"}), + json!({"authenticate_health": "true"}), + json!({"authenticate_health": null}), + json!({"authenticate_health": {}}), + json!({"authenticate_health": false, "unknown": true}), + json!({"authenticate_health": false, "authenticate_health_token_secret": null}), ] { - serde_json::from_value::(json!({"schema_version": 1_u8, "gvl": gvl})) - .expect_err("GVL health policy requires strict objects and field types"); + serde_json::from_value::( + json!({"schema_version": 1_u8, "host": {"gvl": gvl}}), + ) + .expect_err("GVL health policy requires strict objects and field types"); } for health in [ - json!({"enabled": true}), - json!({"enabled": false, "token_secret": "token"}), + json!({"authenticate_health": true}), + json!({"authenticate_health": true, "authenticate_health_token_secret": ""}), + json!({"authenticate_health_token_secret": "token"}), ] { - let policy: OperatorPolicy = serde_json::from_value( - json!({"schema_version": 1_u8, "gvl": {"authenticated_health": health}}), - ) - .unwrap(); + let policy: OperatorPolicy = + serde_json::from_value(json!({"schema_version": 1_u8, "host": {"gvl": health}})) + .unwrap(); policy .validate() .expect_err("health mode and token reference must agree"); } + for document in [ + json!({"schema_version": 1_u8, "gvl": {"authenticate_health": false}}), + json!({"schema_version": 1_u8, "account_defaults": {"gvl": {"authenticate_health": false}}}), + json!({"schema_version": 1_u8, "accounts": {"fake_account": {"gvl": {"authenticate_health": false}}}}), + ] { + serde_json::from_value::(document) + .expect_err("GVL health policy belongs only under host"); + } } #[test] - fn gvl_authenticated_health_validates_reference_bytes_without_logging_values() { + fn gvl_authenticate_health_validates_reference_bytes_without_logging_values() { for reference in ["x".to_owned(), "x".repeat(128), "\u{00e9}".repeat(64)] { let policy: OperatorPolicy = serde_json::from_value(json!({"schema_version": 1_u8, - "gvl": {"authenticated_health": {"enabled": true, "token_secret": reference}}})) + "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": reference}}})) .unwrap(); policy.validate().unwrap(); } @@ -723,7 +709,7 @@ mod tests { "reference\0canary".to_owned(), ] { let policy: OperatorPolicy = serde_json::from_value(json!({"schema_version": 1_u8, - "gvl": {"authenticated_health": {"enabled": true, "token_secret": reference}}})) + "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": reference}}})) .unwrap(); let error = policy.validate().unwrap_err(); assert!(!format!("{error:?}").contains("reference_canary")); diff --git a/crates/auctioneer-config/tests/root_shape.rs b/crates/auctioneer-config/tests/root_shape.rs index ae3a090..1ad40dd 100644 --- a/crates/auctioneer-config/tests/root_shape.rs +++ b/crates/auctioneer-config/tests/root_shape.rs @@ -24,13 +24,19 @@ mod tests { fn fixture() -> Value { json!({ "schema_version": 1_u64, - "host": {"status_response": "", "resource_limits": {"default_tmax_ms": 500_u64}}, + "host": { + "status_response": "", + "resource_limits": {"default_tmax_ms": 500_u64}, + "gvl": { + "authenticate_health": true, + "authenticate_health_token_secret": "health_gvl_token" + } + }, "account_defaults": {"disabled": false, "targeting_prefix": "hb"}, "accounts": {"fake_publisher": {"debug_allow": true}}, "bidders": {"fake_bidder": {"implementation": "fake", "settings": {}, "enabled": false}}, "features": {"gzip_outbound": false}, - "floor_currency": {"expires_at": 2000_i64, "usd_per_unit": {"EUR": 1.25_f64}}, - "gvl": {"authenticated_health": {"enabled": true, "token_secret": "health_gvl_token"}} + "floor_currency": {"expires_at": 2000_i64, "usd_per_unit": {"EUR": 1.25_f64}} }) } @@ -60,14 +66,20 @@ mod tests { input["bidders"]["fake_bidder"]["settings"] ); assert_eq!(canonical["floor_currency"], input["floor_currency"]); - assert_eq!(canonical["gvl"], input["gvl"]); + assert_eq!(canonical["host"]["gvl"], input["host"]["gvl"]); + assert_eq!(canonical["host"]["gvl"]["authenticate_health"], json!(true)); + assert_eq!( + canonical["host"]["gvl"]["authenticate_health_token_secret"], + json!("health_gvl_token") + ); + assert!(canonical.get("gvl").is_none()); let restored: Document = toml::from_str(&toml::to_string(&toml_policy).unwrap()).unwrap(); assert_eq!(serde_json::to_value(restored).unwrap(), canonical); } #[test] fn retired_wrappers_and_unknown_namespaces_are_not_wire_aliases() { - for name in ["startup", "baked", "runtime", "runtim"] { + for name in ["startup", "baked", "runtime", "runtim", "gvl"] { let mut document = fixture(); document .as_object_mut() @@ -153,8 +165,7 @@ mod tests { "/host", "/account_defaults", "/accounts/fake_publisher", - "/gvl", - "/gvl/authenticated_health", + "/host/gvl", ] { for value in [ json!([]), @@ -187,8 +198,7 @@ mod tests { "/bidders/fake_bidder/settings", "/features", "/floor_currency", - "/gvl", - "/gvl/authenticated_health", + "/host/gvl", ] { let mut document = fixture(); document diff --git a/crates/auctioneer-core/src/handlers/health_gvl.rs b/crates/auctioneer-core/src/handlers/health_gvl.rs index 3054c7d..e108a51 100644 --- a/crates/auctioneer-core/src/handlers/health_gvl.rs +++ b/crates/auctioneer-core/src/handlers/health_gvl.rs @@ -259,7 +259,7 @@ async fn authorize(ctx: &RequestContext, presented: Option<&str>) -> bool { return false; } }; - policy.gvl.and_then(|gvl| gvl.authenticated_health) + policy.host.gvl }; let Some(health) = configured_health else { return false; @@ -267,10 +267,13 @@ async fn authorize(ctx: &RequestContext, presented: Option<&str>) -> bool { if deadline.is_expired_at(clock.now()) { return false; } - if !health.enabled { - return true; + match health.authenticate_health { + Some(false) => return true, + None => return false, + Some(true) => {} } - let (Some(reference), Some(bearer)) = (health.token_secret, presented) else { + let (Some(reference), Some(bearer)) = (health.authenticate_health_token_secret, presented) + else { return false; }; let Some(auth) = read_token(ctx.secret_store_default(), &reference, &clock, deadline).await diff --git a/crates/auctioneer-core/tests/health_gvl.rs b/crates/auctioneer-core/tests/health_gvl.rs index 71cc132..b5f5dc4 100644 --- a/crates/auctioneer-core/tests/health_gvl.rs +++ b/crates/auctioneer-core/tests/health_gvl.rs @@ -56,7 +56,7 @@ mod tests { request.extensions_mut().insert(TestClockHandle(clock)); request.extensions_mut().insert(runtime_registry( &serde_json::json!({"schema_version": 1_u8, - "gvl": {"authenticated_health": {"enabled": false}}}), + "host": {"gvl": {"authenticate_health": false}}}), )); let ctx = context(request); let response = health_gvl(ctx).await.expect("handler returns Ok"); diff --git a/crates/auctioneer-core/tests/health_gvl_auth.rs b/crates/auctioneer-core/tests/health_gvl_auth.rs index 8226a49..d89713e 100644 --- a/crates/auctioneer-core/tests/health_gvl_auth.rs +++ b/crates/auctioneer-core/tests/health_gvl_auth.rs @@ -66,8 +66,8 @@ mod tests { async fn status_for(bearer: Option) -> StatusCode { status_with( - json!({"schema_version": 1_u8, "gvl": {"authenticated_health": { - "enabled": true, "token_secret": "custom_gvl_token"}}}), + json!({"schema_version": 1_u8, "host": {"gvl": { + "authenticate_health": true, "authenticate_health_token_secret": "custom_gvl_token"}}}), Some(store(Some(b"t"), 1)), bearer, ) @@ -198,8 +198,8 @@ mod tests { #[test] fn health_policy_and_secret_rotation_apply_per_request() { block_on(async { - let policy = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": { - "enabled": true, "token_secret": "custom_gvl_token"}}}); + let policy = json!({"schema_version": 1_u8, "host": {"gvl": { + "authenticate_health": true, "authenticate_health_token_secret": "custom_gvl_token"}}}); for (token, bearer, expected) in [ (b"first".as_slice(), "Bearer first", StatusCode::OK), ( @@ -219,10 +219,23 @@ mod tests { expected ); } - let unused = store(Some(b"t"), 1); - assert_eq!(status_with(json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": false}}}), - Some(Arc::clone(&unused)), None).await, StatusCode::OK); - assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + for anonymous_policy in [ + json!({"authenticate_health": false}), + json!({"authenticate_health": false, "authenticate_health_token_secret": ""}), + json!({"authenticate_health": false, "authenticate_health_token_secret": "custom_gvl_token"}), + ] { + let unused = store(Some(b"t"), 1); + assert_eq!( + status_with( + json!({"schema_version": 1_u8, "host": {"gvl": anonymous_policy}}), + Some(Arc::clone(&unused)), + None, + ) + .await, + StatusCode::OK + ); + assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + } assert_eq!( status_with(policy, None, Some(HeaderValue::from_static("Bearer t"))).await, StatusCode::UNAUTHORIZED @@ -235,9 +248,15 @@ mod tests { block_on(async { for policy in [ json!({"schema_version": 1_u8}), - json!({"schema_version": 1_u8, "gvl": {}}), - json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": true}}}), - json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": false, "token_secret": "custom_gvl_token"}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": ""}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health_token_secret": "custom_gvl_token"}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "authenticate_health_token_secret": " invalid "}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": "false"}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": null}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "unknown": true}}}), + json!({"schema_version": 1_u8, "gvl": {"authenticate_health": false}}), ] { let unused = store(Some(b"t"), 1); assert_eq!( @@ -251,7 +270,7 @@ mod tests { ); assert_eq!(unused.calls.load(Ordering::SeqCst), 0); } - let policy = json!({"schema_version": 1_u8, "gvl": {"authenticated_health": {"enabled": true, "token_secret": "custom_gvl_token"}}}); + let policy = json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": "custom_gvl_token"}}}); for fault in [ store(None, 0), store(Some(b""), 0), diff --git a/crates/auctioneer-core/tests/health_gvl_docs.rs b/crates/auctioneer-core/tests/health_gvl_docs.rs index 2346067..cbfb0ea 100644 --- a/crates/auctioneer-core/tests/health_gvl_docs.rs +++ b/crates/auctioneer-core/tests/health_gvl_docs.rs @@ -14,8 +14,9 @@ mod tests { "## Operational endpoints (`/_auctioneer/*`)", "`GET /_auctioneer/health/gvl`", "fail-closed", - "[gvl.authenticated_health]", - "token_secret", + "[host.gvl]", + "authenticate_health", + "authenticate_health_token_secret", "restrict the `/_auctioneer/*` path prefix at the ingress layer", "docs/deployment/restrict-operational-endpoints.md", ] { diff --git a/crates/auctioneer-core/tests/serialize_allocation_bound.rs b/crates/auctioneer-core/tests/serialize_allocation_bound.rs index 5402115..12aa0f2 100644 --- a/crates/auctioneer-core/tests/serialize_allocation_bound.rs +++ b/crates/auctioneer-core/tests/serialize_allocation_bound.rs @@ -342,7 +342,7 @@ mod tests { )))); request.extensions_mut().insert(runtime_registry( &serde_json::json!({"schema_version": 1_u8, - "gvl": {"authenticated_health": {"enabled": false}}}), + "host": {"gvl": {"authenticate_health": false}}}), )); context(request) } diff --git a/docs/architecture.md b/docs/architecture.md index ffca9f5..bf0be73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -450,16 +450,21 @@ operator runtime config The authoring file [`auctioneer.toml`](../auctioneer.toml) is the direct payload: required root `schema_version = 1`, `host`, `account_defaults`, `accounts`, -`bidders`, `features`, `gvl` and `floor_currency`. There are no baked/startup/runtime +`bidders`, `features` and `floor_currency`. There are no baked/startup/runtime root wrappers, source modes or backward wire aliases. Builds never read operator policy. The SDK trait wrapper preserves model and wire layout. Accounts are carried in that runtime blob, not fetched from separate account records. Runtime-field environment overrides are applied at push time to keys already present in the source; they are not a live, per-instance policy override channel. GVL health access is -part of that runtime policy: `gvl.authenticated_health` stays closed when omitted, -requires a default Secret Store entry reference when enabled, or permits anonymous -access when explicitly disabled with no reference. The shared handler resolves -policy and the selected secret on every health request; adapters install only the +part of that runtime policy under `host.gvl`: an omitted section or +`authenticate_health` mode stays closed. `authenticate_health = true` requires a +nonempty default Secret Store entry reference in +`authenticate_health_token_secret`; an empty reference is invalid. +`authenticate_health = false` explicitly permits anonymous access with an absent, +empty or valid named reference and never reads the secret. All nonempty references +are validated, including in anonymous mode; a reference without a mode is invalid. +The shared handler resolves policy and, for authenticated health, the selected +secret on every health request; adapters install only the immutable counter scope. Config and secret visibility follow the existing SDK provider lifecycle, including Axum's startup file snapshot. Health credentials use the same request-bound default secret provider as other credentials. @@ -693,7 +698,7 @@ This describes technical enforcement, not legal certification. `/status` is not proof of bidder reachability, usable consent data, or production certification. The internal health route is fail-closed without -valid `gvl.authenticated_health` runtime policy and, when authentication is enabled, +valid `host.gvl` runtime policy and, when authentication is enabled, a valid credential. Explicit anonymous access requires an ingress path restriction; see [internal-route deployment guidance](deployment/restrict-operational-endpoints.md). diff --git a/docs/deployment/restrict-operational-endpoints.md b/docs/deployment/restrict-operational-endpoints.md index 78ca029..4434776 100644 --- a/docs/deployment/restrict-operational-endpoints.md +++ b/docs/deployment/restrict-operational-endpoints.md @@ -2,10 +2,12 @@ auctioneer serves operational endpoints under the `/_auctioneer/*` prefix (`/_auctioneer/health/gvl` today). The shared handler resolves -`gvl.authenticated_health` from the pushed operator policy on every health -request. An omitted section keeps health closed. `enabled = true` requires a -`token_secret` entry name in the default Secret Store. Missing/malformed config, -secret faults, empty secrets, secrets over 4096 bytes and failed authorization +`host.gvl` from the pushed operator policy on every health request. An omitted +section or `authenticate_health` mode keeps health closed. +`authenticate_health = true` requires a nonempty +`authenticate_health_token_secret` entry name in the default Secret Store; an +empty reference is invalid. Missing/malformed config always denies access. +In authenticated mode, secret faults, empty secrets, secrets over 4096 bytes and failed authorization return 401 with a Bearer challenge. Reads use the existing config-fetch deadline; provider APIs may materialize a complete secret before the accepted-size check, and synchronous provider calls cannot be preempted by a cooperative deadline. @@ -14,12 +16,17 @@ and synchronous provider calls cannot be preempted by a cooperative deadline. To allow anonymous access, explicitly configure: ```toml -[gvl.authenticated_health] -enabled = false +[host.gvl] +authenticate_health = false +authenticate_health_token_secret = "" ``` -This mode must omit `token_secret` and requires an ingress restriction for the -prefix. Ingress restriction is recommended defence in depth for authenticated +This mode permits an absent, empty or valid named secret reference and never +reads the secret. Every nonempty reference must be at most 128 UTF-8 bytes, +without ASCII controls or surrounding whitespace, even in anonymous mode. A +reference without a mode, unknown fields and explicit nulls are rejected. +Anonymous access requires an ingress restriction for the prefix. Ingress +restriction is recommended defence in depth for authenticated health as well. Adapters install counter scope only; provider flags and boot credentials do not determine access. See the [secret provisioning mappings](../../README.md#operational-endpoints-_auctioneer). @@ -58,7 +65,7 @@ unauthorised requests to the prefix before they reach the service: A presence check is **not** authentication: `Authorization: junk` satisfies `if (!req.http.Authorization)` and the shared handler permits -anonymous access when `gvl.authenticated_health.enabled = false`. The value has +anonymous access when `host.gvl.authenticate_health = false`. The value has to be VALIDATED, and the comparison has to be constant-time so the token cannot be recovered byte by byte from response timing. @@ -92,7 +99,7 @@ if (req.url.path ~ "^/_auctioneer/") { ``` For authenticated health, use the same credential selected by -`gvl.authenticated_health.token_secret` in the linked default Fastly secret +`host.gvl.authenticate_health_token_secret` in the linked default Fastly secret store (for example `health_gvl_token`). Keeping the two in step means a rotation that misses one layer fails closed rather than open. diff --git a/docs/superpowers/plans/2026-11-10-gvl-health-policy.md b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md index 5a2a859..861275f 100644 --- a/docs/superpowers/plans/2026-11-10-gvl-health-policy.md +++ b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md @@ -4,27 +4,27 @@ **Goal:** Configure GVL health authentication and its secret reference through the shared runtime policy on every adapter. -**Architecture:** Add strict optional `gvl.authenticated_health` configuration. Resolve and authorize only in the shared health handler through bounded SDK stores; adapters install counter scope only. +**Architecture:** Preserve the existing `[host]` and account scopes. Add strict optional `host.gvl` configuration with flat `authenticate_health` and `authenticate_health_token_secret` leaves; accept no root GVL or nested authenticated-health alias. An omitted section or mode stays closed. True requires a nonempty valid reference; false permits an absent, empty or valid named reference and never reads it. Validate every nonempty reference, including with false; a supplied reference without a mode is invalid. Resolve and authorize only in the shared health handler through bounded SDK stores; adapters install counter scope only. **Tech stack:** Rust, Serde, validator, EdgeZero typed extraction and bounded stores; existing native and guest test owners. ## 1. Shared model and handler -- [x] Add failing inline schema tests to `crates/auctioneer-config/src/policy.rs` for the documented nested shape, strict null/unknown/type/required rules and bounded secret references. Run `cargo test --locked --offline -j2 -p auctioneer-config --features config-model --lib policy::tests::gvl` and observe the absent-field failure. -- [x] Add the optional root GVL policy, with separately validated authenticated-health settings. Preserve omission in serialization and existing neutral config fixtures. +- [x] Add failing inline schema tests to `crates/auctioneer-config/src/policy.rs` for `[host.gvl]` with the two flat fields, strict null/unknown/type/mode rules and bounded secret references. Run `cargo test --locked --offline -j2 -p auctioneer-config --features config-model --lib policy::tests::gvl` and observe the absent-field failure. +- [x] Add optional GVL policy under `HostPolicy`, with optional `authenticate_health` and `authenticate_health_token_secret` leaves and nested host validation. Preserve omission in serialization and existing neutral config fixtures; reject root GVL aliases, true with empty references and references without a mode. - [x] Replace boot authorization in `crates/auctioneer-core/src/handlers/health_gvl.rs` with existing runtime resolution and bounded default-secret reads. Retain 401/challenge and SP-only Bearer parsing. Keep secret loading out of SDK eager field metadata. - [x] Replace global health-policy state in `crates/auctioneer-core/src/host_primitives/registry.rs` with `install_health_counter_scope`; retain constant-time `BearerToken` verification. Update exports and existing test owners. -- [x] Extend `crates/auctioneer-core/tests/health_gvl_auth.rs` for config/secret selection, dynamic rotation, denied faults, and unchanged header controls. Adapt payload/observer/allocation test setup to real typed config and funded boot. +- [x] Migrate the real HTTP authorization fixtures to `host.gvl`; preserve config/secret selection, dynamic rotation, denied faults and all authorization-header controls. Assert false with absent, empty and valid named references succeeds without secret reads. Adapt payload/observer/allocation setup without changing independent resource oracles. ## 2. Adapter migration - [x] Replace boot policy readers in Fastly, Cloudflare, Spin and Axum (including baseline probe) with counter-scope installation. Remove obsolete provider flags and update their owning health/manifest tests to verify no provider policy authority and shared runtime behavior. -- [x] Preserve SDK dispatch/logger/envelope order and store wiring. `fastly.toml` keeps `auctioneer_config = ''`; secrets remain env-backed. Cloudflare secret names and Spin variable mapping must match the selected default store entry after the SDK's lowercase conversion. +- [x] Migrate adapter fixtures to `host.gvl` while preserving all eleven Fastly guest scenarios, caller fixture controls, Axum environment child contracts and Cloudflare/Spin status-response assertions. Preserve SDK dispatch/logger/envelope order and store wiring. `fastly.toml` keeps `auctioneer_config = ''`; secrets remain env-backed. Cloudflare secret names and Spin variable mapping must match the selected default store entry after the SDK's lowercase conversion. - [x] Run affected adapter tests and strict test Clippy, serialized with core jobs. Compile all changed guest targets and execute Fastly guest controls when the requisite artifacts are built. ## 3. Operator documentation and verification -- [x] Add the secure reference section to `auctioneer.toml`; update `README.md`, `docs/deployment/restrict-operational-endpoints.md`, active config inventory and docs tests. Leave historical records intact. +- [x] Document `[host.gvl]` and its exact flat fields in `auctioneer.toml`, `README.md`, deployment guidance, architecture, active config inventory and docs tests. State false with absent/empty/valid named references never reads a secret, true with an empty reference is invalid, and omitted mode stays closed. Leave historical records intact. - [x] Run config/core health suites, runtime-config failure/funding contracts, CLI config contracts, adapters, formatting and diff checks. Execute real Fastly/Viceroy health requests with synthetic typed config and a custom secret name; record unavailable checks honestly. - [x] Review final spec compliance and implementation correctness independently, fix findings, and repeat only impacted checks. -- [x] Commit the focused change and push the already-authorized PR branch; update PR 160 description with accurate scope and evidence. Do not deploy or merge. +- [x] Present the corrected wire shape and serialized verification evidence for review. Obtain any required authorization before remote writes, deployment or merge. diff --git a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md index 2b8429c..fae842c 100644 --- a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md +++ b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md @@ -198,7 +198,7 @@ Sources: [limit and target types](../../../crates/auctioneer-runtime/src/budget. | Cloudflare Worker vars/secrets and KV binding names | `S` provider translation and `V` secrets; use declared binding names, not a fictional process environment or enumerable binding map. | | Fastly linked config/secret stores and logging hook | `S` provider resource links/logical aliases and `V` secrets. Key/location restrictions must match the serving SDK path; a generic selector that only works on Axum is not sufficient. | | Hosted Spin variables/KV labels and logging level | `S` hosted-provider translation and `V` secrets; distinguish hosted deployment facts from generic Spin local execution. | -| `gvl.authenticated_health.{enabled,token_secret}` | `P` health-only authorization. Omitted section stays closed; enabled requires a valid default-store secret entry reference; disabled explicitly permits anonymous access and must omit the reference. Resolve through existing funded runtime config on each health request. No adapter policy flag remains. | +| `host.gvl.{authenticate_health,authenticate_health_token_secret}` | `P` health-only authorization. Omitted section or mode stays closed; true requires a nonempty valid default-store secret entry reference and rejects an empty reference. False explicitly permits anonymous access with an absent, empty or valid named reference, and never reads the secret. Validate every nonempty reference even with false; a reference without a mode is invalid. Resolve through existing funded runtime config on each health request. No adapter policy flag remains. | | Configured GVL health secret entry | `V` via the request's existing default Secret Store binding, maximum accepted payload 4096 bytes and existing config-fetch deadline. Fresh lookup per health request; empty/missing/faulted/excessive secrets deny access. Providers may materialize the complete value before the size check; synchronous provider calls have cooperative deadline checks. Axum uses SDK `EDGEZERO__SECRETS__` with ASCII alphanumeric/underscore entry names; Cloudflare uses the literal entry name; Fastly uses the captured linked default alias; Spin lowercases the entry and needs declaration plus component mapping. The local Fastly `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` is provisioning only. Config visibility follows each provider's lifecycle, including Axum's startup file snapshot; secrets rotate through existing provider visibility. | | `usersync_cookie_keyring` | `V`, one existing bounded secret read at the route's UID stage; preserve active/previous key rotation and deadline/cancellation behavior. | | Bidder credential references and coherent bundles | Permitted typed references through `P` within host-approved bindings; secret values `V` through the existing post-privacy/schema bounded reader. No raw secrets in output policy. | diff --git a/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md index 48d9a4b..73f970d 100644 --- a/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md +++ b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md @@ -3,18 +3,26 @@ The operator owns GVL health authentication in `auctioneer.toml`: ```toml -[gvl.authenticated_health] -enabled = true -token_secret = "health_gvl_token" +[host.gvl] +authenticate_health = true +authenticate_health_token_secret = "health_gvl_token" ``` -`enabled` is required when this section is present. Authentication enabled -requires a nonempty secret entry name, at most 128 UTF-8 bytes, without ASCII -controls or leading/trailing whitespace. It is a reference in the declared -default Secret Store, never token bytes. Unknown fields and explicit nulls -are rejected. An omitted section stays closed. `enabled = false` explicitly -permits anonymous access and must omit `token_secret` to avoid conflicting -intent. No adapter-specific flag or environment alias remains authoritative. +The existing `[host]` structure and account scopes remain intact. GVL health +settings belong only to `[host.gvl]`; no root `[gvl]` or nested +`authenticated_health` alias is accepted. Both leaves are optional in the model. +An omitted section or `authenticate_health` mode stays closed. A supplied secret +reference without a mode is invalid. + +`authenticate_health = true` requires a nonempty secret entry name in +`authenticate_health_token_secret`; an absent or empty reference is invalid. +`authenticate_health = false` explicitly permits anonymous access with an absent, +empty or valid named reference, and never reads the secret. Every supplied +nonempty reference must be at most 128 UTF-8 bytes, without ASCII controls or +leading/trailing whitespace, including in anonymous mode. It is a reference in +the declared default Secret Store, never token bytes. Unknown fields, explicit +nulls and wrong types are rejected. No adapter-specific flag or environment +alias remains authoritative. The shared health handler uses the existing funded, bounded runtime-config resolver. It drops the complete policy before fetching the selected secret @@ -44,7 +52,8 @@ entry name literally as a Worker secret. Spin lowercases the entry name and requires a declared, mapped component variable. Fastly uses the entry name in the captured default Secret Store. Provider naming restrictions still apply. -Validation covers strict TOML/JSON round trips, required/contradictory fields, +Validation covers strict TOML/JSON round trips, missing modes and invalid references, +anonymous absent/empty/named references with no secret reads, all authorization-header controls, runtime config updates and secret rotation, renamed default bindings, missing/malformed config and secret faults, bounded and cancelled reads, and independent status/auction availability. Adapter diff --git a/edgezero.toml b/edgezero.toml index e33e97d..f2a1510 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -20,7 +20,7 @@ entry = "crates/auctioneer-core" # Privacy reference-data health: # /_auctioneer/health/gvl (GET) operator-only; fail-closed 401 unless # operator policy grants access through -# gvl.authenticated_health; restrict /_auctioneer/* at the +# host.gvl; restrict /_auctioneer/* at the # ingress (docs/deployment/restrict-operational-endpoints.md) # # User-sync routes: From 3a0b2061628a486f1ab76f94d844446ee2503d72 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:03:55 -0800 Subject: [PATCH 14/14] Fix runtime policy validation and test harnesses --- README.md | 5 +- auctioneer.toml | 5 +- .../tests/usersync_transport.rs | 10 ++- crates/auctioneer-adapter-fastly/src/lib.rs | 4 +- .../tests/manifest_health_stores.rs | 6 +- crates/auctioneer-cli/src/application.rs | 14 +++- .../qualification/harness/native/snapshots.rs | 84 +++++++++++++++---- .../auctioneer-cli/tests/bidders_resources.rs | 2 +- crates/auctioneer-config/src/policy.rs | 17 ++-- .../tests/aux_request_body_bounds.rs | 4 +- .../auctioneer-core/tests/health_gvl_auth.rs | 50 +++++------ crates/auctioneer-core/tests/support/mod.rs | 11 ++- docs/architecture.md | 7 +- .../restrict-operational-endpoints.md | 7 +- .../plans/2026-11-10-gvl-health-policy.md | 15 +++- ...perator-runtime-configuration-inventory.md | 2 +- .../2026-11-10-gvl-health-policy-design.md | 21 +++-- 17 files changed, 181 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index c831c7b..cbbc0a7 100644 --- a/README.md +++ b/README.md @@ -296,8 +296,9 @@ The handler is fail-closed. `authenticate_health_token_secret` names an entry in the declared default Secret Store. Omit the section or `authenticate_health` to keep health closed. `authenticate_health = true` requires a nonempty reference; an empty reference is invalid. To permit anonymous access, set -`authenticate_health = false`; the reference may be absent, empty or a valid entry -name, and the handler never reads it. All nonempty references must be at most 128 +`authenticate_health = false` and omit `authenticate_health_token_secret`; +an empty or named reference alongside false is invalid. The handler never reads +a secret in anonymous mode. Authenticated references must be at most 128 UTF-8 bytes, without ASCII controls or surrounding whitespace. A reference without a mode, unknown fields and explicit nulls are rejected. The shared handler resolves config and, for authenticated health, the selected secret on each health request; diff --git a/auctioneer.toml b/auctioneer.toml index e4c0b9d..bc99001 100644 --- a/auctioneer.toml +++ b/auctioneer.toml @@ -36,8 +36,9 @@ status_response = "auctioneer local" # reference names an entry in the declared default Secret Store, never token bytes. # Nonempty names are 1..128 UTF-8 bytes, without ASCII controls or surrounding # whitespace. authenticate_health = true requires a nonempty reference. -# authenticate_health = false permits an absent, empty or valid named reference -# and never reads the secret; restrict /_auctioneer/* at ingress in that mode. +# To permit anonymous health, set authenticate_health to false and omit the +# secret reference; even an empty reference alongside false is invalid. +# Anonymous health never reads a secret; restrict /_auctioneer/* at ingress. # A reference without authenticate_health, unknown fields and explicit nulls fail. [host.gvl] authenticate_health = true diff --git a/crates/auctioneer-adapter-axum/tests/usersync_transport.rs b/crates/auctioneer-adapter-axum/tests/usersync_transport.rs index 0f917c7..829c5b0 100644 --- a/crates/auctioneer-adapter-axum/tests/usersync_transport.rs +++ b/crates/auctioneer-adapter-axum/tests/usersync_transport.rs @@ -3,7 +3,9 @@ #[cfg(test)] mod tests { use auctioneer_bidder::test_support::serving_catalog; + use auctioneer_core::App as AuctioneerApp; use auctioneer_core::test_support::live_catalog; + use edgezero_core::app::Hooks as _; use edgezero_core::blob_envelope::BlobEnvelope; use serde_json::{Value, json}; use std::env::{temp_dir, var_os}; @@ -77,12 +79,16 @@ mod tests { .create(&dir) .expect("private child directory"); if let Some(blob) = config_blob { + let config_id = AuctioneerApp::stores() + .config + .expect("the production app declares a config store") + .default; DirBuilder::new() .create(dir.join(".edgezero")) .expect("private config directory"); fs::write( - dir.join(".edgezero/local-config-auctioneer_runtime.json"), - serde_json::to_vec(&json!({"auctioneer_runtime": blob})) + dir.join(format!(".edgezero/local-config-{config_id}.json")), + serde_json::to_vec(&json!({(config_id): blob})) .expect("serialize config-store fixture"), ) .expect("private config blob"); diff --git a/crates/auctioneer-adapter-fastly/src/lib.rs b/crates/auctioneer-adapter-fastly/src/lib.rs index 70af1e6..cf7fc6c 100644 --- a/crates/auctioneer-adapter-fastly/src/lib.rs +++ b/crates/auctioneer-adapter-fastly/src/lib.rs @@ -35,7 +35,7 @@ use auctioneer_core::App as AuctioneerApp; #[cfg(target_arch = "wasm32")] use auctioneer_core::host_primitives::{ DelegateToDevStrategy, DevRng, SystemClock, bootstrap_adapter_with_envelope, - install_stderr_boot_logger_with_level, + install_health_counter_scope, install_stderr_boot_logger_with_level, }; #[cfg(target_arch = "wasm32")] use edgezero_adapter_fastly::{FastlyLogging, init_logger, run_app}; @@ -156,7 +156,7 @@ fn install_boot_log() { /// Install the request's immutable health observation scope. #[cfg(target_arch = "wasm32")] fn install_health() -> Result<(), Error> { - auctioneer_core::host_primitives::install_health_counter_scope("request") + install_health_counter_scope("request") .map_err(|err| Error::msg(format!("health counter scope install conflicted: {err}"))) } diff --git a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs index a8eb168..2ee5b4d 100644 --- a/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs +++ b/crates/auctioneer-adapter-fastly/tests/manifest_health_stores.rs @@ -3,10 +3,14 @@ #[cfg(test)] mod tests { use auctioneer_core::App; - use edgezero_core::{app::Hooks, blob_envelope::BlobEnvelope}; + use edgezero_core::app::Hooks; + #[cfg(not(target_arch = "wasm32"))] + use edgezero_core::blob_envelope::BlobEnvelope; + #[cfg(not(target_arch = "wasm32"))] use serde_json::json; use toml::{Value, from_str}; + #[cfg(not(target_arch = "wasm32"))] const TOKEN: &str = "auctioneer-development-only-health-test-token"; #[cfg(all(unix, not(target_arch = "wasm32")))] diff --git a/crates/auctioneer-cli/src/application.rs b/crates/auctioneer-cli/src/application.rs index a5469cd..de14557 100644 --- a/crates/auctioneer-cli/src/application.rs +++ b/crates/auctioneer-cli/src/application.rs @@ -543,8 +543,20 @@ mod config_contract { 2_i32, ), ("[runtime]\n", 2_i32), + ( + "schema_version = 1\n[host.gvl]\nauthenticate_health = false\n", + 0_i32, + ), + ( + "schema_version = 1\n[host.gvl]\nauthenticate_health = false\nauthenticate_health_token_secret = ''\n", + 2_i32, + ), + ( + "schema_version = 1\n[host.gvl]\nauthenticate_health = false\nauthenticate_health_token_secret = 'health_gvl_token'\n", + 2_i32, + ), ]; - assert_eq!(cases.len(), 4); + assert_eq!(cases.len(), 7); for (body, expected) in cases { let (directory, config) = fixture_file(body); let mut manifest: toml::Value = diff --git a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs index e859f01..9fe5d2f 100644 --- a/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs +++ b/crates/auctioneer-cli/src/qualification/harness/native/snapshots.rs @@ -19,7 +19,7 @@ use std::path::{Path, PathBuf}; const ARTIFACT_CAP: usize = 512 << 20_u32; const SNAPSHOT_CAP: usize = 1 << 20_u32; -const SNAPSHOT_FILE: &str = ".edgezero/local-config-auctioneer_runtime.json"; +const SNAPSHOT_FILE: &str = ".edgezero/local-config-auctioneer_config.json"; // The native fixture listener is shared with the runner and frozen-binary probes. const FIXTURE_ENDPOINT: &str = "http://127.0.0.1:8787/openrtb2/auction"; @@ -41,7 +41,7 @@ const PURPOSE_TWO: PurposeTwo = PurposeTwo { #[derive(Deserialize, Serialize)] struct LocalStore { - auctioneer_runtime: String, + auctioneer_config: String, #[serde(flatten)] other: BTreeMap, } @@ -152,7 +152,7 @@ pub fn read_snapshot(dir: &Path) -> Result { let outer: LocalStore = inputs::parse_json(&bytes, SNAPSHOT_CAP) .map_err(|_error| HarnessError::new("snapshot_invalid"))?; let envelope: EnvelopeDigest = - inputs::parse_json(outer.auctioneer_runtime.as_bytes(), SNAPSHOT_CAP) + inputs::parse_json(outer.auctioneer_config.as_bytes(), SNAPSHOT_CAP) .map_err(|_error| HarnessError::new("snapshot_invalid"))?; Ok(Snapshot { config_sha256: envelope.sha256, @@ -165,7 +165,7 @@ pub fn corrupt_snapshot(dir: &Path) -> Result<()> { let bytes = strict_bytes(&path, SNAPSHOT_CAP)?; let mut outer: LocalStore = inputs::parse_json(&bytes, SNAPSHOT_CAP) .map_err(|_error| HarnessError::new("snapshot_invalid"))?; - "{".clone_into(&mut outer.auctioneer_runtime); + "{".clone_into(&mut outer.auctioneer_config); let encoded = io::json_bytes(&outer, SNAPSHOT_CAP)?; if file_digest(&path, SNAPSHOT_CAP)? != Digest::of(&bytes) { return Err(HarnessError::new("input_changed")); @@ -531,12 +531,15 @@ fn freeze_permissions(file: &File) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::{Args, Command, application}; use auctioneer_config_data::types::{Capabilities, Compression}; use auctioneer_core::config::SdkAuctioneerAppConfig; + use clap::Parser as _; use edgezero_core::blob_envelope::BlobEnvelope; use serde_json::{Value, json}; use std::ffi::OsString; use std::fs; + use std::iter::once; use std::slice::from_ref; use validator::Validate as _; @@ -560,7 +563,7 @@ mod tests { json!({"schema_version": 1_u32, "host": {"status_response": "q2_a"}}), "2026-10-15T00:00:00Z".to_owned(), ); - json!({"auctioneer_runtime": serde_json::to_string(&envelope).unwrap(), "other_store": "PRESERVE_OTHER_STORE"}) + json!({"auctioneer_config": serde_json::to_string(&envelope).unwrap(), "other_store": "PRESERVE_OTHER_STORE"}) } fn configurable_info(name: &str) -> BidderInfo { @@ -853,6 +856,55 @@ mod tests { assert_eq!(spec.timeout_ms, 10_000); } + #[test] + fn actual_config_push_produces_a_readable_qualification_snapshot() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("crates/auctioneer-adapter-axum")).unwrap(); + fs::write( + root.path().join("edgezero.toml"), + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../edgezero.toml")), + ) + .unwrap(); + fs::write( + root.path().join("crates/auctioneer-adapter-axum/axum.toml"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../auctioneer-adapter-axum/axum.toml" + )), + ) + .unwrap(); + fs::write(root.path().join("auctioneer.toml"), "schema_version = 1\n").unwrap(); + assert!(!root.path().join(".edgezero").exists()); + let spec = config_push_spec(Path::new("auctioneer-cli"), root.path()); + let args = + Args::try_parse_from(once(OsString::from("auctioneer-cli")).chain(spec.args)).unwrap(); + let Command::Application(command) = args.command else { + panic!("the qualification push must use the application CLI"); + }; + assert_eq!(application::run(command), 0_i32); + // Read the actual SDK writer's file independently of SNAPSHOT_FILE. + let bytes = fs::read( + root.path() + .join(".edgezero/local-config-auctioneer_config.json"), + ) + .unwrap(); + let outer: Value = serde_json::from_slice(&bytes).unwrap(); + let envelope: BlobEnvelope = + serde_json::from_str(outer["auctioneer_config"].as_str().unwrap()).unwrap(); + envelope.verify().unwrap(); + let snapshot = read_snapshot(root.path()).expect("a real push must feed qualification"); + assert_eq!( + snapshot.config_sha256, + Digest::try_from(envelope.sha256).unwrap() + ); + assert_eq!(snapshot.store_sha256, Digest::of(&bytes)); + corrupt_snapshot(root.path()).unwrap(); + assert_eq!( + read_snapshot(root.path()).unwrap_err(), + HarnessError::new("snapshot_invalid") + ); + } + #[tokio::test] async fn install_rejects_invalid_variants_before_files_or_child_processes() { let root = tempfile::tempdir().unwrap(); @@ -882,7 +934,7 @@ mod tests { let outer = valid_store(); let file = snapshot_file(root.path(), &outer); let inner: BlobEnvelope = - serde_json::from_str(outer["auctioneer_runtime"].as_str().unwrap()).unwrap(); + serde_json::from_str(outer["auctioneer_config"].as_str().unwrap()).unwrap(); let before = read_snapshot(root.path()).unwrap(); assert_eq!( before.config_sha256, @@ -906,8 +958,8 @@ mod tests { ); for outer in [ json!({}), - json!({"auctioneer_runtime": {"sha256": "a".repeat(64)}}), - json!({"auctioneer_runtime": "{"}), + json!({"auctioneer_config": {"sha256": "a".repeat(64)}}), + json!({"auctioneer_config": "{"}), ] { snapshot_file(root.path(), &outer); assert_eq!( @@ -924,7 +976,7 @@ mod tests { ] { snapshot_file( root.path(), - &json!({"auctioneer_runtime": serde_json::to_string(&inner).unwrap()}), + &json!({"auctioneer_config": serde_json::to_string(&inner).unwrap()}), ); let error = read_snapshot(root.path()).unwrap_err(); assert_eq!(error, HarnessError::new("snapshot_invalid")); @@ -935,7 +987,7 @@ mod tests { "a".repeat(64), "b".repeat(64) ); - snapshot_file(root.path(), &json!({"auctioneer_runtime": duplicate})); + snapshot_file(root.path(), &json!({"auctioneer_config": duplicate})); assert_eq!( read_snapshot(root.path()).unwrap_err(), HarnessError::new("snapshot_invalid") @@ -944,8 +996,8 @@ mod tests { fs::write( root.path().join(SNAPSHOT_FILE), outer.replacen( - "\"auctioneer_runtime\":", - "\"auctioneer_runtime\":\"{}\",\"auctioneer_runtime\":", + "\"auctioneer_config\":", + "\"auctioneer_config\":\"{}\",\"auctioneer_config\":", 1, ), ) @@ -976,7 +1028,7 @@ mod tests { "[".repeat(129), "]".repeat(129) ); - snapshot_file(root.path(), &json!({"auctioneer_runtime": deep})); + snapshot_file(root.path(), &json!({"auctioneer_config": deep})); assert_eq!( read_snapshot(root.path()).unwrap_err(), HarnessError::new("snapshot_invalid") @@ -984,7 +1036,7 @@ mod tests { let excessive = json!({"sha256": "a".repeat(64), "discarded": vec![0_u8; 1 << 16_u32]}); snapshot_file( root.path(), - &json!({"auctioneer_runtime": serde_json::to_string(&excessive).unwrap()}), + &json!({"auctioneer_config": serde_json::to_string(&excessive).unwrap()}), ); assert_eq!( read_snapshot(root.path()).unwrap_err(), @@ -999,7 +1051,7 @@ mod tests { read_snapshot(root.path()).unwrap(); corrupt_snapshot(root.path()).unwrap(); let outer: Value = serde_json::from_slice(&fs::read(file).unwrap()).unwrap(); - assert_eq!(outer["auctioneer_runtime"], "{"); + assert_eq!(outer["auctioneer_config"], "{"); assert_eq!(outer["other_store"], "PRESERVE_OTHER_STORE"); assert_eq!(outer.as_object().unwrap().len(), 2); assert_eq!( @@ -1011,7 +1063,7 @@ mod tests { #[test] fn corruption_refuses_invalid_outer_json_without_rewriting_it() { let root = tempfile::tempdir().unwrap(); - let file = snapshot_file(root.path(), &json!({"auctioneer_runtime": 1_u8})); + let file = snapshot_file(root.path(), &json!({"auctioneer_config": 1_u8})); let original = fs::read(&file).unwrap(); assert_eq!( corrupt_snapshot(root.path()).unwrap_err(), diff --git a/crates/auctioneer-cli/tests/bidders_resources.rs b/crates/auctioneer-cli/tests/bidders_resources.rs index 21b06d3..145c648 100644 --- a/crates/auctioneer-cli/tests/bidders_resources.rs +++ b/crates/auctioneer-cli/tests/bidders_resources.rs @@ -196,7 +196,7 @@ mod tests { assert_eq!( row["construction_costs"]["fixed_per_auction_bytes"], // Audit/scope, wire shape, and measured native bridge owners. - 800_u64 + 32 + 19_160 + 800_u64 + 32 + (19_160 + 96) ); assert_eq!( row["construction_costs"]["reusable_scratch_bytes"], diff --git a/crates/auctioneer-config/src/policy.rs b/crates/auctioneer-config/src/policy.rs index 65d2bfa..b3a73a8 100644 --- a/crates/auctioneer-config/src/policy.rs +++ b/crates/auctioneer-config/src/policy.rs @@ -109,7 +109,7 @@ mod object_models { skip_serializing_if = "Option::is_none" )] pub authenticate_health: Option, - /// Entry in the declared default Secret Store. Required only when enabled. + /// Default Secret Store entry. Required when authenticated; omit when anonymous. #[serde( default, deserialize_with = "deserialize_optional_value", @@ -361,8 +361,8 @@ impl Validate for GvlPolicy { }); let valid_mode = match (self.authenticate_health, reference) { (Some(true), Some(value)) => !value.is_empty(), - (Some(false), _) | (None, None) => true, - (Some(true), None) | (None, Some(_)) => false, + (Some(false) | None, None) => true, + (Some(true), None) | (Some(false) | None, Some(_)) => false, }; if !valid_reference || !valid_mode { errors.add( @@ -623,12 +623,10 @@ mod tests { use validator::Validate as _; #[test] - fn gvl_authenticate_health_round_trips_explicit_modes_and_empty_disabled_secret() { + fn gvl_authenticate_health_round_trips_consistent_explicit_modes() { for health in [ json!({"authenticate_health": true, "authenticate_health_token_secret": "health_gvl_token"}), json!({"authenticate_health": false}), - json!({"authenticate_health": false, "authenticate_health_token_secret": ""}), - json!({"authenticate_health": false, "authenticate_health_token_secret": "health_gvl_token"}), json!({}), ] { let input = json!({"schema_version": 1_u8, "host": {"gvl": health}}); @@ -671,6 +669,8 @@ mod tests { for health in [ json!({"authenticate_health": true}), json!({"authenticate_health": true, "authenticate_health_token_secret": ""}), + json!({"authenticate_health": false, "authenticate_health_token_secret": ""}), + json!({"authenticate_health": false, "authenticate_health_token_secret": "health_gvl_token"}), json!({"authenticate_health_token_secret": "token"}), ] { let policy: OperatorPolicy = @@ -679,6 +679,11 @@ mod tests { policy .validate() .expect_err("health mode and token reference must agree"); + let restored: OperatorPolicy = + toml::from_str(&toml::to_string(&policy).unwrap()).unwrap(); + restored + .validate() + .expect_err("TOML authoring must reject the same conflicting modes"); } for document in [ json!({"schema_version": 1_u8, "gvl": {"authenticate_health": false}}), diff --git a/crates/auctioneer-core/tests/aux_request_body_bounds.rs b/crates/auctioneer-core/tests/aux_request_body_bounds.rs index 673e2be..b8e0702 100644 --- a/crates/auctioneer-core/tests/aux_request_body_bounds.rs +++ b/crates/auctioneer-core/tests/aux_request_body_bounds.rs @@ -257,7 +257,9 @@ mod tests { ("/status", StatusCode::OK, 1), ("/info/bidders", StatusCode::OK, 1), (detail_path.as_str(), StatusCode::OK, 1), - ("/_auctioneer/health/gvl", StatusCode::UNAUTHORIZED, 0), + // Health resolves its access policy once through bounded config, + // including when the caller has not presented a bearer token. + ("/_auctioneer/health/gvl", StatusCode::UNAUTHORIZED, 1), ] { for (body, framing) in bodies(DEFAULT_CAP_BYTES) { let reads = fixture.reads(); diff --git a/crates/auctioneer-core/tests/health_gvl_auth.rs b/crates/auctioneer-core/tests/health_gvl_auth.rs index d89713e..20848f3 100644 --- a/crates/auctioneer-core/tests/health_gvl_auth.rs +++ b/crates/auctioneer-core/tests/health_gvl_auth.rs @@ -219,23 +219,18 @@ mod tests { expected ); } - for anonymous_policy in [ - json!({"authenticate_health": false}), - json!({"authenticate_health": false, "authenticate_health_token_secret": ""}), - json!({"authenticate_health": false, "authenticate_health_token_secret": "custom_gvl_token"}), - ] { - let unused = store(Some(b"t"), 1); - assert_eq!( - status_with( - json!({"schema_version": 1_u8, "host": {"gvl": anonymous_policy}}), - Some(Arc::clone(&unused)), - None, - ) - .await, - StatusCode::OK - ); - assert_eq!(unused.calls.load(Ordering::SeqCst), 0); - } + let anonymous_store = store(Some(b"t"), 1); + assert_eq!( + status_with( + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false}}}), + Some(Arc::clone(&anonymous_store)), + None, + ) + .await, + StatusCode::OK + ); + assert_eq!(anonymous_store.calls.load(Ordering::SeqCst), 0); + drop(anonymous_store); assert_eq!( status_with(policy, None, Some(HeaderValue::from_static("Bearer t"))).await, StatusCode::UNAUTHORIZED @@ -252,23 +247,22 @@ mod tests { json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": ""}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health_token_secret": "custom_gvl_token"}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "authenticate_health_token_secret": ""}}}), + json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "authenticate_health_token_secret": "custom_gvl_token"}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "authenticate_health_token_secret": " invalid "}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": "false"}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": null}}}), json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": false, "unknown": true}}}), json!({"schema_version": 1_u8, "gvl": {"authenticate_health": false}}), ] { - let unused = store(Some(b"t"), 1); - assert_eq!( - status_with( - policy, - Some(Arc::clone(&unused)), - Some(HeaderValue::from_static("Bearer t")) - ) - .await, - StatusCode::UNAUTHORIZED - ); - assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + for bearer in [None, Some(HeaderValue::from_static("Bearer t"))] { + let unused = store(Some(b"t"), 1); + assert_eq!( + status_with(policy.clone(), Some(Arc::clone(&unused)), bearer).await, + StatusCode::UNAUTHORIZED + ); + assert_eq!(unused.calls.load(Ordering::SeqCst), 0); + } } let policy = json!({"schema_version": 1_u8, "host": {"gvl": {"authenticate_health": true, "authenticate_health_token_secret": "custom_gvl_token"}}}); for fault in [ diff --git a/crates/auctioneer-core/tests/support/mod.rs b/crates/auctioneer-core/tests/support/mod.rs index b8653b1..0fc8b6c 100644 --- a/crates/auctioneer-core/tests/support/mod.rs +++ b/crates/auctioneer-core/tests/support/mod.rs @@ -3,12 +3,11 @@ //! //! Handler-path tests that need non-default runtime config (GDPR //! enabled, an `[accounts.]` entry, a host alias) would otherwise see -//! only baked defaults, because `resolve_runtime_config` silently -//! degrades to `AuctioneerAppConfig::default()` when no config store is -//! bound. This helper drives the REAL loader — it registers an in-memory -//! `ConfigStore` under the `"auctioneer_runtime"` id that the handler's -//! `from_store(ctx, "auctioneer_runtime", None)` call then reads — rather -//! than introducing a second config path. +//! a missing-config error without a bound store. This helper registers an +//! in-memory `ConfigStore` and declares its default entry in the request +//! registry. The handler's `AppConfig::from_request` follows that binding +//! through the existing funded loader. Fixture ids are independent of the +//! production manifest's default store; this introduces no second config path. //! //! The body sits inside a `#[cfg(test)]` module because clippy's //! `allow-*-in-tests` exemptions key off a `cfg(test)` ancestor, and an diff --git a/docs/architecture.md b/docs/architecture.md index bf0be73..453938e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -460,9 +460,10 @@ part of that runtime policy under `host.gvl`: an omitted section or `authenticate_health` mode stays closed. `authenticate_health = true` requires a nonempty default Secret Store entry reference in `authenticate_health_token_secret`; an empty reference is invalid. -`authenticate_health = false` explicitly permits anonymous access with an absent, -empty or valid named reference and never reads the secret. All nonempty references -are validated, including in anonymous mode; a reference without a mode is invalid. +`authenticate_health = false` explicitly permits anonymous access and requires +the secret reference to be omitted; even an empty reference is invalid in this +mode. Anonymous health never reads a secret. Authenticated references are +validated; a reference without a mode is invalid. The shared handler resolves policy and, for authenticated health, the selected secret on every health request; adapters install only the immutable counter scope. Config and secret visibility follow the existing SDK diff --git a/docs/deployment/restrict-operational-endpoints.md b/docs/deployment/restrict-operational-endpoints.md index 4434776..efaafea 100644 --- a/docs/deployment/restrict-operational-endpoints.md +++ b/docs/deployment/restrict-operational-endpoints.md @@ -18,12 +18,11 @@ To allow anonymous access, explicitly configure: ```toml [host.gvl] authenticate_health = false -authenticate_health_token_secret = "" ``` -This mode permits an absent, empty or valid named secret reference and never -reads the secret. Every nonempty reference must be at most 128 UTF-8 bytes, -without ASCII controls or surrounding whitespace, even in anonymous mode. A +This mode requires the secret reference to be omitted and never reads a secret. +An empty or named reference alongside false is invalid. Authenticated references +must be at most 128 UTF-8 bytes, without ASCII controls or surrounding whitespace. A reference without a mode, unknown fields and explicit nulls are rejected. Anonymous access requires an ingress restriction for the prefix. Ingress restriction is recommended defence in depth for authenticated diff --git a/docs/superpowers/plans/2026-11-10-gvl-health-policy.md b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md index 861275f..496fd07 100644 --- a/docs/superpowers/plans/2026-11-10-gvl-health-policy.md +++ b/docs/superpowers/plans/2026-11-10-gvl-health-policy.md @@ -4,7 +4,9 @@ **Goal:** Configure GVL health authentication and its secret reference through the shared runtime policy on every adapter. -**Architecture:** Preserve the existing `[host]` and account scopes. Add strict optional `host.gvl` configuration with flat `authenticate_health` and `authenticate_health_token_secret` leaves; accept no root GVL or nested authenticated-health alias. An omitted section or mode stays closed. True requires a nonempty valid reference; false permits an absent, empty or valid named reference and never reads it. Validate every nonempty reference, including with false; a supplied reference without a mode is invalid. Resolve and authorize only in the shared health handler through bounded SDK stores; adapters install counter scope only. +**Architecture:** Preserve the existing `[host]` and account scopes. Add strict optional `host.gvl` configuration with flat `authenticate_health` and `authenticate_health_token_secret` leaves; accept no root GVL or nested authenticated-health alias. An omitted section or mode stays closed. True requires a nonempty valid reference; false requires the reference to be omitted and never reads a secret. An empty or named reference with false, or any reference without a mode, is invalid. Resolve and authorize only in the shared health handler through bounded SDK stores; adapters install counter scope only. + +**Maintainer decision:** The November 10 scope and host placement were explicitly requested in the maintainer session. During PR #160 review follow-up the maintainer chose "Reject any secret reference when false". [Issue #164](https://github.com/IABTechLab/auctioneer/issues/164) records this scope under epic #124; the linked specification records the rationale. These decisions do not substitute for independent review or merge approval. **Tech stack:** Rust, Serde, validator, EdgeZero typed extraction and bounded stores; existing native and guest test owners. @@ -24,7 +26,16 @@ ## 3. Operator documentation and verification -- [x] Document `[host.gvl]` and its exact flat fields in `auctioneer.toml`, `README.md`, deployment guidance, architecture, active config inventory and docs tests. State false with absent/empty/valid named references never reads a secret, true with an empty reference is invalid, and omitted mode stays closed. Leave historical records intact. +- [x] Document `[host.gvl]` and its exact flat fields in `auctioneer.toml`, `README.md`, deployment guidance, architecture, active config inventory and docs tests. State false requires an omitted reference and never reads a secret, true with an empty reference is invalid, and omitted mode stays closed. Leave historical records intact. - [x] Run config/core health suites, runtime-config failure/funding contracts, CLI config contracts, adapters, formatting and diff checks. Execute real Fastly/Viceroy health requests with synthetic typed config and a custom secret name; record unavailable checks honestly. - [x] Review final spec compliance and implementation correctness independently, fix findings, and repeat only impacted checks. - [x] Present the corrected wire shape and serialized verification evidence for review. Obtain any required authorization before remote writes, deployment or merge. + +## 4. Review corrections + +- [ ] In `policy.rs` and `health_gvl_auth.rs`, reproduce contradictory false/reference acceptance, then reject both empty and named references through the shared validator. Keep anonymous access without a reference and assert no secret reads. +- [ ] Update `usersync_transport.rs` to derive the file and entry name from the generated App's config-store default. Update the qualification snapshot file/key, and add a real CLI parser/push/readback control in `snapshots.rs`. +- [ ] Apply the existing independent 96-byte layout delta to `bidders_resources.rs`; retain all ceilings and workload. Expect one bounded policy read for admitted health in `aux_request_body_bounds.rs`; preserve oversized-body refusal before config work. +- [ ] Import Fastly's health-scope installer in its WASI branch, repair the reference-file prose without weakening extraction, and update the shared loader harness documentation. +- [ ] Run the affected config, CLI, native transport, health and reference-example targets; run the exact Fastly target lint and the full prescribed feature/target suite from the workflows. Record commands, exit codes and actual output; pending or blocked checks remain explicit. +- [ ] Push verified corrections, reply in each original review thread with evidence, verify the current non-draft head, and hand back to REVIEWER. CREATOR leaves thread resolution and independent acceptance to REVIEWER. diff --git a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md index fae842c..fd866cb 100644 --- a/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md +++ b/docs/superpowers/specs/2026-11-02-operator-runtime-configuration-inventory.md @@ -198,7 +198,7 @@ Sources: [limit and target types](../../../crates/auctioneer-runtime/src/budget. | Cloudflare Worker vars/secrets and KV binding names | `S` provider translation and `V` secrets; use declared binding names, not a fictional process environment or enumerable binding map. | | Fastly linked config/secret stores and logging hook | `S` provider resource links/logical aliases and `V` secrets. Key/location restrictions must match the serving SDK path; a generic selector that only works on Axum is not sufficient. | | Hosted Spin variables/KV labels and logging level | `S` hosted-provider translation and `V` secrets; distinguish hosted deployment facts from generic Spin local execution. | -| `host.gvl.{authenticate_health,authenticate_health_token_secret}` | `P` health-only authorization. Omitted section or mode stays closed; true requires a nonempty valid default-store secret entry reference and rejects an empty reference. False explicitly permits anonymous access with an absent, empty or valid named reference, and never reads the secret. Validate every nonempty reference even with false; a reference without a mode is invalid. Resolve through existing funded runtime config on each health request. No adapter policy flag remains. | +| `host.gvl.{authenticate_health,authenticate_health_token_secret}` | `P` health-only authorization. Omitted section or mode stays closed; true requires a nonempty valid default-store secret entry reference. False explicitly permits anonymous access only with the reference omitted; empty or named references alongside false are invalid. Anonymous health never reads a secret. A reference without a mode is invalid. Resolve through existing funded runtime config on each health request. No adapter policy flag remains. | | Configured GVL health secret entry | `V` via the request's existing default Secret Store binding, maximum accepted payload 4096 bytes and existing config-fetch deadline. Fresh lookup per health request; empty/missing/faulted/excessive secrets deny access. Providers may materialize the complete value before the size check; synchronous provider calls have cooperative deadline checks. Axum uses SDK `EDGEZERO__SECRETS__` with ASCII alphanumeric/underscore entry names; Cloudflare uses the literal entry name; Fastly uses the captured linked default alias; Spin lowercases the entry and needs declaration plus component mapping. The local Fastly `AUCTIONEER__RUNTIME__HEALTH_GVL_TOKEN` is provisioning only. Config visibility follows each provider's lifecycle, including Axum's startup file snapshot; secrets rotate through existing provider visibility. | | `usersync_cookie_keyring` | `V`, one existing bounded secret read at the route's UID stage; preserve active/previous key rotation and deadline/cancellation behavior. | | Bidder credential references and coherent bundles | Permitted typed references through `P` within host-approved bindings; secret values `V` through the existing post-privacy/schema bounded reader. No raw secrets in output policy. | diff --git a/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md index 73f970d..397d320 100644 --- a/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md +++ b/docs/superpowers/specs/2026-11-10-gvl-health-policy-design.md @@ -1,5 +1,11 @@ # GVL health policy in operator configuration +The maintainer approved this scope for PR #160 on November 10, 2026: move health +authentication into `auctioneer.toml`, retain the existing host wrapper, and +"put gvl under host" with the two fields below. [Issue #164](https://github.com/IABTechLab/auctioneer/issues/164), +under epic #124, records the scope and acceptance criteria. This is the +maintainer's design decision, not independent review or merge approval. + The operator owns GVL health authentication in `auctioneer.toml`: ```toml @@ -16,10 +22,14 @@ reference without a mode is invalid. `authenticate_health = true` requires a nonempty secret entry name in `authenticate_health_token_secret`; an absent or empty reference is invalid. -`authenticate_health = false` explicitly permits anonymous access with an absent, -empty or valid named reference, and never reads the secret. Every supplied -nonempty reference must be at most 128 UTF-8 bytes, without ASCII controls or -leading/trailing whitespace, including in anonymous mode. It is a reference in +`authenticate_health = false` explicitly permits anonymous access and requires +the secret reference to be omitted. An empty or named reference alongside false +is invalid. In the review follow-up, the maintainer explicitly chose "Reject any +secret reference when false": conflicting authentication intent must fail +validation instead of silently opening health. This supersedes the permissive +false/reference combinations introduced in `7142df7`. Anonymous mode never reads +the secret. A reference in authenticated mode must be at most 128 UTF-8 bytes, +without ASCII controls or leading/trailing whitespace. It is a reference in the declared default Secret Store, never token bytes. Unknown fields, explicit nulls and wrong types are rejected. No adapter-specific flag or environment alias remains authoritative. @@ -53,7 +63,8 @@ requires a declared, mapped component variable. Fastly uses the entry name in the captured default Secret Store. Provider naming restrictions still apply. Validation covers strict TOML/JSON round trips, missing modes and invalid references, -anonymous absent/empty/named references with no secret reads, +anonymous access without a reference or secret read, refusal of empty/named +references alongside false, all authorization-header controls, runtime config updates and secret rotation, renamed default bindings, missing/malformed config and secret faults, bounded and cancelled reads, and independent status/auction availability. Adapter