From b740265ce180f9d660be9e37a362f2312eeee1f5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 18 Aug 2026 19:38:03 +0100 Subject: [PATCH 001/133] Add a pluggable Edge Cookie provider seam with the built-in HMAC provider First of five PRs decomposing the provider and permission epic. The EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back, and KV keying through the selected provider, so a vendor identifier round-trips verbatim instead of being dropped by the built-in shape check. - [ec] provider selector with per-provider [ec.providers.] blocks. The deprecated [ec] passphrase form still starts for one release cycle: it maps to provider = "hmac" with a deprecation warning, and a configuration carrying both forms is rejected. provider = "none" spells explicit statelessness. A configured block that is not the selected provider is rejected at startup, as is a block with no selector. - Global identifier bounds enforced by core at mint, read-back, and cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte cap. An identifier outside the bounds is rejected loudly, never rewritten, so the cookie value and the identity-graph key can never silently diverge. - The identity graph is keyed by the provider's canonical form of the identifier (normalize_id_for_kv), so equivalent representations of one identity share one row. - Request evidence abstraction (crate::evidence) giving providers read access to the client IP, headers (including cookies), URL path, and query parameters. - Adapter injection seam: RuntimeServices carries an optional vendor provider, so a vendor provider lives in its own crate and core never names it. A selected provider the adapter does not inject fails the request loudly rather than silently running stateless. - Provider generate failures log at error level with the request proceeding stateless. Edge Cookie creation and use stay gated by the existing consent context exactly as on main, including with no provider selected; the permission model replaces that input in the third PR of this series. Config migration: move [ec] passphrase to [ec.providers.hmac] and set [ec] provider = "hmac". The old form keeps working for one release with a warning. Passphrases shorter than 32 characters are now rejected at startup; previously they were accepted. The design spec for this slice and the next lives at docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the 2026-07-31 draft revised to match the implementation with a revision-record table of every divergence. Every provider carries a mandatory registered four-character code (provider-code-registry.md): core mints {code}~value, checks the code at read-back, and keys the identity graph with it, so identifiers from different providers can never collide and a switch of provider cannot silently adopt another provider's identities. The built-in hmac provider mints hmac~. and dual-reads its pre-envelope bare form for one release cycle. --- crates/edgecookie/README.md | 9 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 6 + .../trusted-server-adapter-fastly/src/app.rs | 17 +- .../trusted-server-adapter-fastly/src/main.rs | 3 + .../src/middleware.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + crates/trusted-server-core/src/config.rs | 3 + .../trusted-server-core/src/config_payload.rs | 22 +- crates/trusted-server-core/src/ec/cookies.rs | 203 ++--- crates/trusted-server-core/src/ec/finalize.rs | 153 +++- .../trusted-server-core/src/ec/generation.rs | 80 +- crates/trusted-server-core/src/ec/identify.rs | 23 +- crates/trusted-server-core/src/ec/mod.rs | 772 +++++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 517 ++++++++++++ crates/trusted-server-core/src/edge_cookie.rs | 154 +++- crates/trusted-server-core/src/evidence.rs | 293 +++++++ .../src/integrations/google_tag_manager.rs | 6 + .../src/integrations/prebid.rs | 3 + .../src/integrations/registry.rs | 2 +- crates/trusted-server-core/src/lib.rs | 1 + .../src/platform/test_support.rs | 22 + .../trusted-server-core/src/platform/types.rs | 38 +- .../src/response_privacy.rs | 3 + crates/trusted-server-core/src/settings.rs | 406 ++++++++- .../trusted-server-core/src/test_support.rs | 4 + .../configs/trusted-server.integration.toml | 5 +- .../tests/parity.rs | 3 + trusted-server.example.toml | 26 +- 32 files changed, 2515 insertions(+), 277 deletions(-) create mode 100644 crates/edgecookie/README.md create mode 100644 crates/trusted-server-core/src/ec/provider.rs create mode 100644 crates/trusted-server-core/src/evidence.rs diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..186b8c304 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,9 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in default provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. This +directory is a placeholder until a vendor provider is added. diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..6a78e042d 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -193,6 +193,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index ed199e6bf..5de96be92 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..8c1aa2894 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -209,6 +209,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index fb498ce4e..93b0f9db9 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 4ba61f603..bd999b56d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -825,7 +825,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Publisher pages read consent data, so the consent KV store must be @@ -1372,6 +1372,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1441,6 +1444,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1888,6 +1894,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2541,6 +2550,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2956,6 +2968,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 8a73a80db..1b49eba4d 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -527,6 +527,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 283f16255..39c86ffdd 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -320,6 +320,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..a9698350c 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -236,6 +236,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..2389ccebc 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index ad4f66460..ac57e069d 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -470,6 +470,9 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..1b3da4785 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,26 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (mint, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider minted it. +// This is intentionally broader than the built-in format validator // (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// `<64-hex>.<6-alphanumeric>` structure of the HMAC provider; an opaque +// vendor identifier only has to fit the alphabet and the length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +58,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +129,28 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +200,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +267,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..d09d8097a 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,12 +8,11 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::settings::Settings; use super::EcContext; +use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; @@ -29,12 +28,16 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved consent gate, last-seen updates, cookie +/// reconciliation, Prebid EID ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,19 +50,27 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. + for (name, value) in ec_context.response_headers() { + response.headers_mut().insert(name, value.clone()); + } + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (consent is simply not granted) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_consent_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -82,8 +93,8 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); } @@ -156,13 +167,13 @@ fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { let mut hashes = HashSet::new(); if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) + && ec_context.accepts_id(cookie_ec_id) { hashes.insert(cookie_ec_id.to_owned()); } if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) + && ec_context.accepts_id(active_ec_id) { hashes.insert(active_ec_id.to_owned()); } @@ -219,6 +230,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +244,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +254,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,6 +262,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -275,7 +290,14 @@ mod tests { #[test] fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); let ids = withdrawal_ec_ids(&ec_context); @@ -295,6 +317,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -313,6 +336,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -331,6 +355,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -402,7 +427,7 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -459,6 +484,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +519,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +554,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +582,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +607,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +621,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +640,78 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" ); + } + + #[test] + fn closed_consent_gate_writes_no_ec_cookie() { + // The gate: with the consent gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The consent gate is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a closed consent gate must not write a ts-ec cookie" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..a3bfb1dd6 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -11,7 +11,6 @@ use rand::Rng; use sha2::Sha256; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +80,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Mints an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, JA4, or HTTP/2 fingerprint, so distinct part lists +/// cannot collide. A provider that derives identity from several request signals +/// (for example a Fastly provider over JA4, H2, IP, and UA) passes them as +/// separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -175,7 +194,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +266,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +275,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..eeadaa290 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,7 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + if !ec_context.ec_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -332,7 +331,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +350,12 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +469,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +511,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +536,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +570,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +596,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +649,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +675,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..6bf07625f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -45,6 +45,7 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; @@ -60,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +73,12 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput, build_provider}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -126,7 +131,15 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub fn get_ec_id(req: &Request) -> Result, Report> { let parsed = parse_ec_from_request(req)?; - let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); + // Accept the coded form (any provider's `{code}~value` within the global + // identifier bounds) and the legacy bare HMAC form. Provider-aware + // ownership lives in `EcContext`; this helper only reads the string. + let ec_id = parsed + .cookie_ec + .filter(|v| match provider::split_provider_code(v) { + (Some(_), value) => !value.is_empty() && cookies::ec_id_has_only_allowed_chars(v), + (None, value) => is_valid_ec_id(value), + }); if let Some(ref id) = ec_id { log::trace!("Existing EC ID found: {}", log_id(id)); } @@ -152,6 +165,10 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether Edge Cookie creation is allowed for this request. Resolved once + /// at construction from the consent context and read via + /// [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +178,27 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured, so + /// a deployment with no Edge Cookie provider clones nothing. A provider reads + /// these through [`RequestInfo`](crate::evidence::RequestInfo) at generate + /// time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -200,13 +238,48 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // Build the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier. Building it needs + // no request data, so nothing is cloned from the request. + let ec_provider = services.ec_provider(); + let selected_provider: Option> = + build_provider(&settings.ec, ec_provider.clone())?.map(Arc::from); + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -223,11 +296,20 @@ impl EcContext { kv_store: None, }); + // Gate Edge Cookie creation and use on the request's consent context + // (jurisdiction and consent signals). With no provider selected nothing + // may mint or use an identifier, so the gate is closed rather than open + // by default. Downstream consumers read the stored result via + // [`EcContext::ec_allowed`] rather than re-deriving it. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|_| consent::ec_consent_granted(&consent)); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +319,15 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: geo_info.cloned(), device_signals: None, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -265,22 +353,105 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: EC creation not permitted (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { + // EC generation needs the client IP; checked after the cheap skip + // guards so a stateless deployment on a host with no client IP does not + // log spurious errors. The provider reads it borrowed at generate time + // (see [`generate_with_provider`]), so nothing is cloned here. + if self.client_ip.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + })); + } + + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly: the configured path builds it from + /// settings, and tests pass one in to observe the [`IdentityInput`] a + /// provider receives. The request evidence captured at read time (client + /// IP, headers, and the URL path and query) is passed borrowed through + /// [`RequestInfo`](crate::evidence::RequestInfo), so a provider can read + /// cookies and request parameters at generate time; the built-ins read + /// only the client IP. The skip guards (existing EC, consent gate) + /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is + /// unavailable, the provider fails to derive an identifier, or persisting a + /// generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP; a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at mint: the cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +468,13 @@ impl EcContext { .as_ref() .map(DeviceSignals::to_kv_device); - if let Err(err) = graph.create_or_revive(ec_value, &entry) { + // Key the identity graph by the provider's canonical form of the + // identifier, so equivalent representations of one identity share + // one row. The built-in normalization lowercases only the HMAC + // hash segment; an opaque vendor provider overrides it to the + // identity function. + let kv_key = crate::ec::provider::provider_kv_key(ec_provider, ec_value); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { log::error!( "Failed to create or revive EC entry for id '{}' after generation: {err:?}", log_id(ec_value), @@ -319,6 +496,21 @@ impl EcContext { self.ec_value.as_deref() } + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. Falls back to the built-in shape when no provider is + /// configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.selected_provider.as_ref().map_or_else( + || is_valid_ec_id(value), + |provider| provider::provider_owns_id(provider.as_ref(), value), + ) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +540,8 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed), + /// which is resolved once at construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -365,6 +558,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -383,10 +584,13 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether Edge Cookie creation is allowed for this request. + /// + /// Resolved once at construction from the consent context (see + /// [`consent::ec_consent_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +603,51 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Creates a test-only `EcContext` whose creation gate is derived from the + /// consent context, matching the production construction path. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// an explicit gate. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); + Self::new_for_test_gated(ec_value, consent, ec_allowed) + } + + /// Creates a test-only `EcContext` with an explicit creation gate. + /// + /// `ec_allowed` stands in for the gating decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -439,15 +659,22 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -461,6 +688,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +696,15 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } } @@ -494,6 +728,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::ProviderCode; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +748,488 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0cc") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes + // no header snapshot; a caller that has headers supplies them. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the consent gate is open and generation runs in provider tests. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case) — the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("opaque".to_owned()); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + } + + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ev") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some("evidence".to_owned()); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider minted should be committed under its code" + ); + } + + /// A provider that mints an opaque, mixed-case, non-HMAC identifier at the + /// edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0so") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("server-opaque".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge mints and persists. + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be minted" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + /// A provider that mints an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at mint rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0il") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("illegal".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("an identifier outside the alphabet should be rejected at mint"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a mint rejection" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + #[derive(Debug)] + struct CanonicalizingProvider; + + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ca") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("canonical".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + } + + #[test] + fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should generate"); + let minted = ec.ec_value().expect("should mint an identifier"); + assert!( + minted.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {minted}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..ec6eeef3c --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,517 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are +//! wired by dependency injection: a provider's constructor takes the services it +//! needs (for example [`RequestInfo`] for the client IP) +//! (the adapter, through [`build_provider`]) supplies instances per request. A +//! provider that needs a service the host does not supply cannot be built, so +//! the request stops rather than silently degrading. +//! +//! The provider is selected by configuration, with no default. [`HmacProvider`] +//! is the built-in server-side implementation that derives the identifier from +//! the client IP using HMAC, the behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::generation; + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data (client IP, User-Agent, headers, host signals) reaches a +/// provider through the services injected into its constructor, not through this +/// struct. This carries only the per-request gating context a provider may read +/// for behavior beyond gating. The gate has already confirmed Edge Cookie +/// storage is allowed before `generate` is called. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The request's consent context, when available, for provider-specific + /// logic. The core gates generation before calling the provider, so a + /// provider reads this only to forward or record consent. [`HmacProvider`] + /// ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration. A provider derives the +/// identifier at the edge in [`generate`](Self::generate), and the page +/// response sets the `ts-ec` cookie. +/// +/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it +/// cannot derive an identifier at the edge, so the request proceeds without an +/// Edge Cookie rather than failing. +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in +/// `docs/superpowers/specs/provider-code-registry.md` and never reused. The +/// code appears as the `{code}~` prefix of every identifier the provider +/// mints, so identifiers from different providers can never collide in the +/// cookie, the identity graph, or a withdrawal, and each identifier records +/// which provider created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code, validating the registry format. + /// + /// # Panics + /// + /// Panics when `code` is not exactly four characters of `[a-z0-9]`. Codes + /// are compile-time literals, so the panic fires in tests and never on a + /// request path. + #[must_use] + pub const fn new(code: &'static str) -> Self { + let bytes = code.as_bytes(); + assert!( + bytes.len() == 4, + "provider code must be exactly four characters" + ); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + assert!( + b.is_ascii_lowercase() || b.is_ascii_digit(), + "provider code characters must be [a-z0-9]" + ); + i += 1; + } + Self(code) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form for one release cycle so deployed cookies +/// keep working across the migration. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == "hmac" && provider.accepts_id(value), + } +} + +/// The full minted identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it mints. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// `docs/superpowers/specs/provider-code-registry.md` before it can exist, + /// so no two providers can ever mint colliding identifiers. Core applies + /// the code at mint and checks it at read-back, and the provider itself + /// only ever sees its own value part. + fn code(&self) -> ProviderCode; + + /// Derives an Edge Cookie identifier from the provider's injected services + /// and the request's gating context. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`] and + /// the other core providers. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + "hmac" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("hmac") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector, +/// injecting the services it needs. +/// +/// This is the composition root for the built-in providers. The per-request +/// [`RequestInfo`] is passed borrowed to +/// [`generate`](EdgeCookieProvider::generate) at call time rather than stored, so +/// no request snapshot is cloned here. Returns `Ok(None)` when no provider is +/// selected, so the caller stays stateless. +/// +/// # Errors +/// +/// None of the built-in constructions fail today. The `Result` is the seam for +/// a provider whose construction can fail (for example one requiring a host +/// service the deployment does not supply), so such a misconfiguration fails +/// loudly rather than minting a degraded identifier. +pub fn build_provider( + ec: &Ec, + injected: Option>, +) -> Result>, Report> { + let Some(key) = ec.provider.as_deref() else { + return Ok(None); + }; + let provider: Option> = match key { + // Explicit statelessness: the same meaning as omitting the selector. + "none" => None, + "hmac" => ec + .providers + .hmac + .as_ref() + .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + // Any other key names a vendor or host provider the adapter injects + // through [`RuntimeServices`](crate::platform::RuntimeServices), the same + // seam the device and geo providers use, so core never names a vendor. + // The injected provider is used when its own id matches the selected key, + // and its `[ec.providers.]` block is read by the adapter that built + // it. A selected key with no matching injected provider is a deployment + // error: fail loudly rather than silently running stateless. + other => { + let provider = injected + .filter(|provider| provider.id() == other) + .map(|provider| Box::new(SharedProvider(provider)) as _); + if provider.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{other}` is selected but this deployment's \ + adapter does not provide it" + ), + })); + } + provider + } + }; + Ok(provider) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0in") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some("acme".to_owned()), + ..Ec::default() + }; + + let err = build_provider(&ec, None) + .expect_err("selecting a provider the adapter does not inject should error"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + } +} diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..e77f1a82c 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,24 +11,29 @@ use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::{IdentityInput, build_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; +#[cfg(test)] use crate::platform::RuntimeServices; #[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and mints no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. @@ -36,18 +41,39 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (for example the client IP) borrowed at + // call time, so nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path gates creation on the request's consent context at + // the call site, and the built-in provider reads neither that result nor + // the consent context, so + // they are not threaded here. + let generated = provider.generate(&request_info, &IdentityInput::default())?; + let generated = crate::ec::provider::GeneratedEdgeCookie { + id: generated + .id + .map(|value| crate::ec::provider::apply_provider_code(provider.as_ref(), &value)), + response_headers: generated.response_headers, + }; + Ok(generated.id) } /// Gets an existing EC ID from the request. @@ -99,7 +125,10 @@ pub fn get_ec_id(req: &Request) -> Result, Report, -) -> Result> { +) -> Result, Report> { if let Some(id) = get_ec_id(req)? { - return Ok(id); + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -130,7 +161,7 @@ pub fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { +) -> Result, Report> { get_or_generate_ec_id_from_http_request(settings, services, req) } @@ -141,6 +172,7 @@ mod tests { use http::{HeaderName, header}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; @@ -155,13 +187,24 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID via edge_cookie"); - let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); + let bare_here = id_here + .strip_prefix("hmac~") + .expect("should carry the hmac provider code"); assert_eq!( - crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(bare_here), crate::ec::ec_hash(&id_canonical), "should produce the same identity hash prefix as the canonical generator" ); @@ -178,6 +221,10 @@ mod tests { } fn is_ec_id_format(value: &str) -> bool { + // The coded envelope: hmac~<64hex>.<6alnum>. + let Some(value) = value.strip_prefix("hmac~") else { + return false; + }; let mut parts = value.split('.'); let hmac_part = match parts.next() { Some(part) => part, @@ -206,11 +253,27 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is minted" ); } @@ -219,10 +282,12 @@ mod tests { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID with client IP"); - let id_without_ip = generate_ec_id(&settings, &noop_services()) - .expect("should generate EC ID without client IP"); + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; @@ -235,22 +300,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a fresh mint always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -278,7 +349,8 @@ mod tests { assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -294,7 +366,8 @@ mod tests { assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -326,7 +399,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -344,7 +418,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -369,7 +444,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs new file mode 100644 index 000000000..78e0d21ca --- /dev/null +++ b/crates/trusted-server-core/src/evidence.rs @@ -0,0 +1,293 @@ +//! Service interfaces injected into providers. +//! +//! Trusted Server wires providers by dependency injection. A provider's +//! constructor takes the services it needs as `Arc`, and the adapter +//! (the composition root) supplies instances per request. A provider that needs +//! a service the host does not supply cannot be built, so the request stops +//! rather than silently degrading. +//! +//! These traits are the service interfaces. Request-scoped data outlives the +//! live request only when snapshotted, so an implementation owns its data where +//! needed ([`OwnedRequestInfo`] is the built-in owned snapshot). + +use http::HeaderMap; + +/// Read-only access to the current request's basic information. +/// +/// The request data any host can supply: the normalized client IP, the +/// User-Agent, and request headers. A provider receives it by reference at call +/// time (`generate`/`detect`), reads what it needs, and does not retain it. +pub trait RequestInfo: Send + Sync + core::fmt::Debug { + /// The normalized client IP, or `""` when the host cannot determine it. + fn client_ip(&self) -> &str; + + /// The `User-Agent` header value, or `""` when absent. + fn user_agent(&self) -> &str; + + /// An arbitrary request header by name (case-insensitive), or `None`. + /// + /// Request cookies are read through this, from the `Cookie` header (a + /// provider that stores values in cookies parses them from it). + fn header(&self, name: &str) -> Option<&str>; + + /// The names of all request headers present, for a provider that enumerates + /// evidence (for example to forward client hints). The default is empty. + fn header_names(&self) -> Vec<&str> { + Vec::new() + } + + /// The request path (the URL path, without the query string), or `""` when + /// request info was built without a URL. + /// + /// A provider reads the request target through this together with + /// [`query`](Self::query); `RequestInfo` is the evidence abstraction, so more + /// request accessors can be added here (as defaulted methods) without + /// breaking existing implementations. + fn path(&self) -> &str { + "" + } + + /// The raw request query string (the part after `?`, without the leading + /// `?`), or `""` when the request carried none. + /// + /// A provider reads request parameters through this, or the + /// [`query_param`](Self::query_param) convenience. The default is empty, for + /// request info built without a URL. + fn query(&self) -> &str { + "" + } + + /// The first value of query parameter `name`, percent-decoded, or `None` + /// when the parameter is absent. + /// + /// Parses [`query`](Self::query) with `application/x-www-form-urlencoded` + /// rules, matching how the browser encodes query parameters. + fn query_param(&self, name: &str) -> Option { + url::form_urlencoded::parse(self.query().as_bytes()) + .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) + } +} + +/// An owned [`RequestInfo`] built from a request snapshot. +/// +/// Owns the client IP and a header snapshot, for a context that cannot borrow +/// the live request for the duration of the call. The request path uses +/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future +/// host whose request data cannot be borrowed. +#[derive(Debug, Default, Clone)] +pub struct OwnedRequestInfo { + client_ip: String, + headers: HeaderMap, + path: String, + query: String, +} + +impl OwnedRequestInfo { + /// Builds owned request info from the client IP and a header snapshot. + /// + /// The request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: String, headers: HeaderMap) -> Self { + Self { + client_ip, + headers, + path: String::new(), + query: String::new(), + } + } + + /// Attaches the request target (URL path and query string) to this snapshot, + /// so a provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: String, query: String) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for OwnedRequestInfo { + fn client_ip(&self) -> &str { + &self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers.keys().map(http::HeaderName::as_str).collect() + } + + fn path(&self) -> &str { + &self.path + } + + fn query(&self) -> &str { + &self.query + } +} + +/// A borrowed [`RequestInfo`] over the live request, with no allocation. +/// +/// The composition root builds one per request from the normalized client IP and +/// an optional borrow of the request headers, then passes it to a provider by +/// shared reference at call time (`generate`/`detect`). It borrows rather than +/// owns, so it must not outlive the request. A provider reads it during the call +/// and does not retain it, so no per-request `HeaderMap` clone is needed. +#[derive(Debug)] +pub struct BorrowedRequestInfo<'a> { + client_ip: &'a str, + headers: Option<&'a HeaderMap>, + path: &'a str, + query: &'a str, +} + +impl<'a> BorrowedRequestInfo<'a> { + /// Borrows request info from the client IP and optional request headers. + /// + /// Pass `None` for headers on a path that only needs the client IP. The + /// request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { + Self { + client_ip, + headers, + path: "", + query: "", + } + } + + /// Attaches the borrowed request target (URL path and query string), so a + /// provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for BorrowedRequestInfo<'_> { + fn client_ip(&self) -> &str { + self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .and_then(|headers| headers.get(http::header::USER_AGENT)) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .and_then(|headers| headers.get(name)) + .and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers + .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) + .unwrap_or_default() + } + + fn path(&self) -> &str { + self.path + } + + fn query(&self) -> &str { + self.query + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers_with_cookie() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should parse cookie header"), + ); + headers + } + + #[test] + fn query_param_decodes_and_selects_the_first_value() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/page".to_owned(), "id=a%20b&id=second&flag=1".to_owned()); + + assert_eq!( + info.query_param("id").as_deref(), + Some("a b"), + "should percent-decode and return the first value for a repeated key" + ); + assert_eq!(info.query_param("flag").as_deref(), Some("1")); + assert_eq!( + info.query_param("missing"), + None, + "an absent parameter should be None" + ); + } + + #[test] + fn path_and_query_accessors_return_the_request_target() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/a/b".to_owned(), "x=1".to_owned()); + assert_eq!(info.path(), "/a/b"); + assert_eq!(info.query(), "x=1"); + } + + #[test] + fn request_info_defaults_to_an_empty_target() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), HeaderMap::new()); + assert_eq!(info.path(), "", "path should default to empty"); + assert_eq!(info.query(), "", "query should default to empty"); + assert_eq!( + info.query_param("id"), + None, + "query_param over an empty query should be None" + ); + } + + #[test] + fn a_provider_reads_cookies_from_the_header() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), headers_with_cookie()); + assert_eq!( + info.header("cookie"), + Some("client-id=abc123; ts-ec=xyz"), + "cookies are read through the Cookie header" + ); + } + + #[test] + fn borrowed_request_info_exposes_the_same_target() { + let headers = headers_with_cookie(); + let info = BorrowedRequestInfo::new("203.0.113.5", Some(&headers)) + .with_request_target("/page", "id=abc123"); + + assert_eq!(info.path(), "/page"); + assert_eq!(info.query(), "id=abc123"); + assert_eq!(info.query_param("id").as_deref(), Some("abc123")); + assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); + } +} diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 162e9eb8c..4aceca9dc 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1566,6 +1566,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] @@ -1599,6 +1602,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d0cf37275..47b899a13 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3057,6 +3057,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..4399dc912 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -974,7 +974,7 @@ impl IntegrationRegistry { // may lack consent signals such as the Sec-GPC header. if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { - log::warn!("EC generation failed for integration proxy: {err:?}"); + log::error!("EC generation failed for integration proxy: {err:?}"); } } else { log::debug!( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..3801ed9f3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod evidence; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 917f1bf50..cecb902fa 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -688,6 +688,28 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider, so a test +/// can exercise the adapter-injection path an opaque-identifier vendor provider +/// reaches core through. +pub(crate) fn noop_services_with_ec_provider( + ec_provider: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + // A fixed client IP so the generate path (which requires one) can run. + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .ec_provider(ec_provider) + .build() +} + /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 7a3d09334..a6c535ba9 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -9,6 +9,7 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::ec::provider::EdgeCookieProvider; /// Geographic information extracted from a request. /// @@ -18,7 +19,7 @@ use super::{ pub struct GeoInfo { /// City name. pub city: String, - /// Two-letter country code. + /// ISO 3166-1 alpha-2 country code, for example `US` or `GB`. pub country: String, /// Continent name. pub continent: String, @@ -28,7 +29,8 @@ pub struct GeoInfo { pub longitude: f64, /// DMA (Designated Market Area) / metro code. pub metro_code: i64, - /// Region code. + /// ISO 3166-2 subdivision code without the country prefix, for example `CA` + /// for California, or `None` when no region resolves. pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. @@ -188,6 +190,12 @@ pub struct RuntimeServices { pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// A vendor or host Edge Cookie provider the adapter injects, selected when + /// `[ec] provider` names it. `None` when only the built-in providers are in + /// use. This is the seam that lets a vendor Edge Cookie provider live in its + /// own crate and be injected, so core never names a vendor (the same + /// pattern as [`geo`](Self::geo)). + pub(crate) ec_provider: Option>, } impl RuntimeServices { @@ -275,6 +283,17 @@ impl RuntimeServices { &self.client_info } + /// Returns the adapter-injected Edge Cookie provider, when one is wired. + /// + /// `None` when the deployment uses only the built-in providers (which core + /// builds itself). A vendor or host provider is injected here by the + /// adapter, so [`build_provider`](crate::ec::provider::build_provider) can + /// return it without core naming the vendor. + #[must_use] + pub fn ec_provider(&self) -> Option> { + self.ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -342,6 +361,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -357,6 +377,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + ec_provider: None, } } @@ -436,6 +457,18 @@ impl RuntimeServicesBuilder { self } + /// Set the adapter-injected Edge Cookie provider. + /// + /// Optional: leave it unset for a deployment that uses only the built-in + /// providers. Set it to inject a vendor or host provider selected by + /// `[ec] provider`, so the provider lives in its own crate and core never + /// names it. + #[must_use] + pub fn ec_provider(mut self, ec_provider: Arc) -> Self { + self.ec_provider = Some(ec_provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -476,6 +509,7 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + ec_provider: self.ec_provider, } } } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 8674429ea..d2a413a71 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -219,6 +219,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0c78ab00b..a650a6622 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -476,9 +476,38 @@ impl EcPartner { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The key of the Edge Cookie identity provider to activate. + /// + /// Names one of the blocks under [`providers`](Self::providers), for + /// example `"hmac"`. Set it in the `[ec]` TOML section or override it with + /// the `TRUSTED_SERVER__ec__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. When absent, no + /// Edge Cookie is generated and Trusted Server runs statelessly; the + /// explicit `"none"` spells the same choice. Selecting a provider whose + /// block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, + + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` + /// block and logs a deprecation warning, so a fleet can move configuration + /// and binaries independently. A configuration carrying both the old and + /// the new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Configuration blocks for the available Edge Cookie identity providers. + /// + /// Each provider has its own optional `[ec.providers.]` block. The + /// [`provider`](Self::provider) selector names which one is active, so a + /// block can be configured (or kept) without being the one in use. + #[serde(default)] + #[validate(nested)] + pub providers: EcProviders, /// Fastly KV store name for the EC identity graph. #[serde(default)] @@ -566,6 +595,191 @@ impl Ec { } Ok(()) } + + /// Validates that the selected provider names a configured block. + /// + /// When [`provider`](Self::provider) is set, the matching block under + /// [`providers`](Self::providers) must be present, so a deployment that + /// selects a provider (in TOML or via the environment override) but has not + /// configured it fails fast at startup rather than silently running + /// stateless. When no provider is selected, Trusted Server runs statelessly + /// and this check passes. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is unknown or its `[ec.providers.]` block is absent. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + let Some(key) = self.provider.as_deref() else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec.providers.*] blocks are configured but no [ec] provider is \ + selected. Set [ec] provider = \"\" to activate one, or \ + remove the blocks to run statelessly" + .to_owned(), + })); + } + return Ok(()); + }; + + // `"none"` is explicit statelessness: the same meaning as omitting + // the selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + if key == "none" { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + } + + let configured = match key { + "hmac" => self.providers.hmac.is_some(), + // A vendor or host provider the adapter injects is configured when + // its `[ec.providers.]` block is present. The adapter validates + // the block's own contents when it builds the provider. + other => self.providers.has_vendor(other), + }; + + if !configured { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })); + } + + // Every configured block must be the selected one. An unreferenced + // block is almost always a mistake (a mistyped selector or a stale + // block), and accepting it silently invites configuration drift. + let mut unreferenced: Vec = Vec::new(); + if self.providers.hmac.is_some() && key != "hmac" { + unreferenced.push("hmac".to_owned()); + } + for vendor_key in self.providers.vendor_keys() { + if vendor_key != key { + unreferenced.push(vendor_key.to_owned()); + } + } + if unreferenced.is_empty() { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec.providers.{}] is configured but `{key}` is selected. Remove the \ + unselected block, or correct the selector", + unreferenced.join("], [ec.providers.") + ), + })) + } + } + + /// Migrates the deprecated `[ec] passphrase` form to the provider layout. + /// + /// A configuration still carrying the old key keeps working for one + /// release cycle: it maps to `provider = "hmac"` with the passphrase in + /// the `[ec.providers.hmac]` block, and a deprecation warning names the + /// new location. A configuration carrying both forms is rejected so a + /// half-edited file fails loudly instead of one form silently winning. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when both the deprecated + /// key and any part of the provider configuration are present. + pub fn migrate_legacy_passphrase(&mut self) -> Result<(), Report> { + let Some(passphrase) = self.passphrase.take() else { + return Ok(()); + }; + if self.provider.is_some() || !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] passphrase (deprecated) and the [ec] provider configuration \ + are both present. Keep exactly one form: move the passphrase to \ + [ec.providers.hmac] and delete the old key" + .to_owned(), + })); + } + log::warn!( + "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ + set [ec] provider = \"hmac\"" + ); + self.provider = Some("hmac".to_owned()); + self.providers.hmac = Some(HmacProviderConfig { passphrase }); + Ok(()) + } +} + +/// Configuration blocks for the available Edge Cookie identity providers. +/// +/// Each provider is configured in its own `[ec.providers.]` block, for +/// example: +/// +/// ```toml +/// [ec.providers.hmac] +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// The active provider is chosen by the [`Ec::provider`] selector, so a block +/// can be present without being in use. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct EcProviders { + /// The built-in HMAC-over-client-IP provider, keyed `hmac`. + #[serde(default)] + #[validate(nested)] + pub hmac: Option, + + /// Configuration blocks for vendor or host providers that live in their own + /// crates and are injected by the adapter. Any `[ec.providers.]` block + /// whose key is not a built-in is captured here as raw values, and the + /// adapter that constructs the provider deserializes its own block into the + /// vendor crate's config type. Core never names a vendor, so a new provider + /// adds nothing here. + #[serde(flatten)] + vendor: HashMap, +} + +impl EcProviders { + /// Returns the raw configuration block for a vendor provider `key`, or + /// `None` when no `[ec.providers.]` block is present. The adapter that + /// builds the provider deserializes this into its own config type. + #[must_use] + pub fn vendor_config(&self, key: &str) -> Option<&JsonValue> { + self.vendor.get(key) + } + + /// Whether a vendor provider configuration block is present for `key`. + #[must_use] + pub fn has_vendor(&self, key: &str) -> bool { + self.vendor.contains_key(key) + } + + /// The keys of the configured vendor provider blocks. + pub(crate) fn vendor_keys(&self) -> impl Iterator { + self.vendor.keys().map(String::as_str) + } + + /// Whether any provider configuration block is present. + /// + /// Used by [`Ec::validate_provider_selection`] to reject a half-migrated + /// configuration that carries provider blocks with no selector, which + /// would otherwise silently run stateless. + #[must_use] + pub fn is_empty(&self) -> bool { + self.hmac.is_none() && self.vendor.is_empty() + } +} + +/// Configuration for the built-in HMAC Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.hmac]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct HmacProviderConfig { + /// Publisher passphrase used as the HMAC key for EC generation. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -2917,6 +3131,8 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_passphrase()?; + settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3007,8 +3223,10 @@ impl Settings { pub fn reject_placeholder_secrets(&self) -> Result<(), Report> { let mut insecure_fields: Vec = Vec::new(); - if Ec::is_placeholder_passphrase(self.ec.passphrase.expose()) { - insecure_fields.push("ec.passphrase".to_owned()); + if let Some(hmac) = &self.ec.providers.hmac + && Ec::is_placeholder_passphrase(hmac.passphrase.expose()) + { + insecure_fields.push("ec.providers.hmac.passphrase".to_owned()); } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -4371,9 +4589,14 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), - "test-secret-key-32-bytes-minimum" + settings.ec.provider.as_deref(), + Some("hmac"), + "test settings should select the hmac EC provider" ); + let Some(hmac) = &settings.ec.providers.hmac else { + panic!("test settings should configure the hmac EC provider"); + }; + assert_eq!(hmac.passphrase.expose(), "test-secret-key-32-bytes-minimum"); settings.validate().expect("Failed to validate settings"); } @@ -4540,6 +4763,34 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn provider_selection_rejects_a_selector_without_a_configured_block() { + // Point the selector at a provider whose `[ec.providers.]` block is + // absent, mirroring a deployment that sets the env override to a + // provider it never configured. + let toml_str = + crate_test_settings_str().replace(r#"provider = "hmac""#, r#"provider = "acme""#); + + let err = Settings::from_toml(&toml_str) + .expect_err("selecting an unconfigured provider should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "unconfigured provider selection should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -4646,6 +4897,25 @@ mod tests { ); } + #[test] + fn provider_blocks_without_a_selector_are_rejected() { + // A half-migrated configuration that carries an [ec.providers.hmac] + // block but never selects it would silently run stateless; reject it + // at startup instead. + let toml_str = crate_test_settings_str().replace("provider = \"hmac\"\n", ""); + + let err = Settings::from_toml(&toml_str) + .expect_err("a provider block with no selector should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_policy_validation_rejects_unsafe_config() { let missing_ttl = format!( @@ -4759,6 +5029,35 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_passphrase() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_deref(), + Some("hmac"), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + ec.providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4836,6 +5135,74 @@ mod tests { ); } + #[test] + fn legacy_passphrase_alongside_provider_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + provider: Some("hmac".to_owned()), + ..Ec::default() + }; + let err = ec + .migrate_legacy_passphrase() + .expect_err("both forms present should be rejected"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn provider_none_is_explicit_stateless() { + let ec = Ec { + provider: Some("none".to_owned()), + ..Ec::default() + }; + ec.validate_provider_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn provider_none_with_configured_blocks_is_rejected() { + let ec = Ec { + provider: Some("none".to_owned()), + providers: EcProviders { + hmac: Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }), + ..EcProviders::default() + }, + ..Ec::default() + }; + assert!( + ec.validate_provider_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn an_unselected_provider_block_is_rejected() { + // A vendor selector with the vendor block present, plus a stray hmac + // block, is almost always a stale or mistyped configuration. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\"", + "provider = \"acme\"\n\n [ec.providers.acme]\n api_key = \"example\"", + ); + let err = Settings::from_toml(&toml_str) + .expect_err("a configured but unselected block should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -5230,7 +5597,9 @@ origin_host_header_overide = "www.example.com""#, let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.providers.hmac = Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5869,6 +6238,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -5900,6 +6272,9 @@ origin_host_header_overide = "www.example.com""#, max_buffered_body_bytes = 0 [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ); @@ -7054,6 +7429,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -7387,6 +7765,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7471,6 +7852,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7507,6 +7891,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7549,6 +7936,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 5f094c0d2..f755a0bcd 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -31,7 +31,11 @@ pub mod tests { rewrite_attributes = ["href", "link", "url"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d8e35d179..fa6fef6e8 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -10,10 +10,13 @@ origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" [ec] -passphrase = "integration-test-ec-secret-padded-32" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.providers.hmac] +passphrase = "integration-test-ec-secret-padded-32" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..853a48ee9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -43,6 +43,9 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..3b644c51d 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,10 +6,13 @@ # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only three sections are REQUIRED for the server to start and pass validation: +# Only two sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Edge Cookie identity is optional and stays off until an [ec] provider is +# selected, so no identity secret is needed to start. See the Edge Cookie +# section below. # # Everything below those is OPTIONAL. Most optional blocks are commented out — # uncomment and edit one to enable it — but a few integrations are kept as active @@ -70,12 +73,16 @@ proxy_secret = "change-me-proxy-secret" # ----------------------------------------------------------------------------- -# REQUIRED — Edge Cookie (EC) identity +# OPTIONAL — Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder -# in production (deploy validation rejects known placeholders). -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup), or set the selector with the +# TRUSTED_SERVER__ec__provider environment variable. The built-in hmac provider +# is host-neutral; a vendor provider needs its own cargo feature. +# provider = "hmac" # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. @@ -86,6 +93,13 @@ pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Built-in HMAC provider block. Uncomment it together with the +# `provider = "hmac"` selector above. The secret used to derive EC identifiers +# must be >= 32 chars and non-placeholder in production (deploy validation +# rejects known placeholders). +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Optional identity partners (SSP/DSP/identity vendors). Each needs a real, # non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via # private config, not this template. From d2ad2a91d300414348a56e9a9f4593872c45c237 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:06:40 +0100 Subject: [PATCH 002/133] Accept the provider-code envelope on the partner-facing identifier paths Since the provider-code envelope, the mint path issues identifiers as hmac~{64hex}.{6alnum}, and that is the value identify hands to partners. Pull sync, batch sync and the admin lookup still validated the bare shape through is_valid_ec_id, so pull sync skipped every freshly minted identifier, batch sync answered invalid_ec_id for the value partners were given, and the admin lookup answered 400. CI stayed green because the lifecycle scenario seeds a bare cookie. is_valid_ec_id now accepts the hmac envelope as well as the legacy bare form and rejects any other provider's code, and normalize_ec_id_for_kv keeps the envelope so the key matches the one written at mint. Tests cover the validator, the normalizer and each of the three call sites with a coded identifier. --- crates/trusted-server-core/src/ec/admin.rs | 13 ++- .../trusted-server-core/src/ec/batch_sync.rs | 17 ++++ .../trusted-server-core/src/ec/generation.rs | 92 +++++++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 2 +- .../trusted-server-core/src/ec/pull_sync.rs | 19 ++++ 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6219af7a9..6cf4b3921 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -361,7 +361,7 @@ fn requested_ec_id(req: &Request) -> Result; const ALPHANUMERIC_CHARSET: &[u8] = @@ -139,12 +144,33 @@ pub fn ec_hash(ec_id: &str) -> &str { /// so internal EC IDs are already lowercase. This normalization is a /// defense-in-depth measure for EC IDs submitted by external partners /// (via batch sync) that may use uppercase hex. +/// +/// A minted identifier carries the built-in provider's code envelope +/// (`hmac~` before the value, see +/// [`PROVIDER_CODE_SEPARATOR`](super::provider::PROVIDER_CODE_SEPARATOR)), +/// and partners echo that form back, so the envelope is kept and only the +/// value inside it is lowercased. That keeps the key identical to the one +/// written at mint. An identifier under any other provider's code is not +/// HMAC-shaped and is returned unchanged, because only that provider knows +/// how to normalize it. #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { - let mut parts = ec_id.splitn(2, '.'); + let (code, bare) = match split_provider_code(ec_id) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE => (Some(code), bare), + (Some(_), _) => return ec_id.to_owned(), + (None, bare) => (None, bare), + }; + let mut parts = bare.splitn(2, '.'); let hash = parts.next().unwrap_or_default(); let suffix = parts.next().unwrap_or_default(); - format!("{}.{}", hash.to_ascii_lowercase(), suffix) + match code { + Some(code) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}.{}", + hash.to_ascii_lowercase(), + suffix + ), + None => format!("{}.{}", hash.to_ascii_lowercase(), suffix), + } } /// Checks whether a string is a valid 64-character hex EC hash prefix. @@ -158,17 +184,30 @@ pub fn is_valid_ec_hash(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } -/// Checks whether a string matches the expected EC ID format. +/// Checks whether a string is a built-in HMAC identifier, bare or enveloped. +/// +/// The bare format is `{64hex}.{6alnum}` where the first part is a +/// 64-character **lowercase** hex string and the second part is a 6-character +/// alphanumeric string. Only lowercase hex is accepted; callers must +/// normalize before validation to prevent duplicate KV keys from case-variant +/// EC IDs. The HMAC prefix is lowercase because it comes from `hex::encode`; +/// the random suffix allows mixed-case alphanumeric characters by +/// construction. /// -/// The format is `{64hex}.{6alnum}` where the first part is a 64-character -/// **lowercase** hex string and the second part is a 6-character alphanumeric -/// string. Only lowercase hex is accepted; callers must normalize before -/// validation to prevent duplicate KV keys from case-variant EC IDs. The HMAC -/// prefix is lowercase because it comes from `hex::encode`; the random suffix -/// allows mixed-case alphanumeric characters by construction. +/// A minted identifier carries the provider-code envelope, `hmac~` before +/// the bare value, and that is the form the partner-facing paths (pull sync, +/// batch sync, the admin lookup) receive, so both the enveloped and the +/// legacy bare form are accepted. An identifier under any other provider's +/// code is not an HMAC identifier and is rejected here: those paths accept +/// only the built-in provider's identifiers today. #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { - let mut parts = value.split('.'); + let bare = match split_provider_code(value) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE => bare, + (Some(_), _) => return false, + (None, bare) => bare, + }; + let mut parts = bare.split('.'); let Some(hmac_part) = parts.next() else { return false; }; @@ -371,4 +410,37 @@ mod tests { "should reject extra segments" ); } + + #[test] + fn is_valid_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "a".repeat(64)); + assert!( + is_valid_ec_id(&coded), + "should accept a minted identifier carrying the hmac code" + ); + } + + #[test] + fn is_valid_ec_id_rejects_other_provider_codes() { + let coded = format!("t0op~{}.ABC123", "a".repeat(64)); + assert!( + !is_valid_ec_id(&coded), + "should reject an identifier carrying another provider's code" + ); + } + + #[test] + fn normalize_ec_id_for_kv_keeps_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "A".repeat(64)); + assert_eq!( + normalize_ec_id_for_kv(&coded), + format!("hmac~{}.ABC123", "a".repeat(64)), + "should lowercase the hash and keep the code prefix" + ); + assert_eq!( + normalize_ec_id_for_kv("t0op~MixedCase"), + "t0op~MixedCase", + "should leave another provider's identifier unchanged" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 6bf07625f..c9417a9fc 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -858,7 +858,7 @@ mod tests { use crate::platform::test_support::noop_services_with_ec_provider; // A vendor identifier that is deliberately not the built-in HMAC shape - // (no dot, mixed case) — the exact value the built-in check would drop. + // (no dot, mixed case), the exact value the built-in check would drop. const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..1caa498c0 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -711,4 +711,23 @@ mod tests { "hour 1 rotation should move beta to front" ); } + + #[test] + fn build_pull_sync_context_accepts_a_minted_coded_ec_id() { + let consent = ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + // The form the mint path produces since the provider-code envelope. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + + let context = build_pull_sync_context(&ec_context) + .expect("should build pull sync context for a coded HMAC identifier"); + assert_eq!( + context.ec_id(), + ec_id, + "should dispatch the coded identifier as minted" + ); + } } From b2623cb00a1ba7e00bf5ba620a1cc7dccd324b2f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:29:20 +0100 Subject: [PATCH 003/133] Rename the legacy passphrase migration so CodeQL stops tainting Settings CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged. --- crates/trusted-server-core/src/settings.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a650a6622..851690bc8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -492,7 +492,7 @@ pub struct Ec { /// Deprecated location of the HMAC passphrase, read so a configuration /// written for the previous release still starts. /// - /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// [`migrate_legacy_ec_layout`](Self::migrate_legacy_ec_layout) maps it /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` /// block and logs a deprecation warning, so a fleet can move configuration /// and binaries independently. A configuration carrying both the old and @@ -690,7 +690,7 @@ impl Ec { /// /// Returns [`TrustedServerError::Configuration`] when both the deprecated /// key and any part of the provider configuration are present. - pub fn migrate_legacy_passphrase(&mut self) -> Result<(), Report> { + pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { let Some(passphrase) = self.passphrase.take() else { return Ok(()); }; @@ -3131,7 +3131,7 @@ impl Settings { }) })?; - settings.ec.migrate_legacy_passphrase()?; + settings.ec.migrate_legacy_ec_layout()?; settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -5035,7 +5035,7 @@ mod tests { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), ..Ec::default() }; - ec.migrate_legacy_passphrase() + ec.migrate_legacy_ec_layout() .expect("should migrate the deprecated form"); assert_eq!( ec.provider.as_deref(), @@ -5143,7 +5143,7 @@ mod tests { ..Ec::default() }; let err = ec - .migrate_legacy_passphrase() + .migrate_legacy_ec_layout() .expect_err("both forms present should be rejected"); assert!( matches!( From 918f96cdde52256d0404c2b8b0357c23e6722c85 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:02:37 +0100 Subject: [PATCH 004/133] Stop serving without identity when a selected provider is unavailable A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1) --- crates/trusted-server-adapter-axum/src/app.rs | 122 ++++++++++++++--- .../tests/routes.rs | 50 +++++++ .../src/app.rs | 115 +++++++++++++--- .../tests/routes.rs | 50 +++++++ .../trusted-server-adapter-fastly/src/app.rs | 15 +++ crates/trusted-server-adapter-spin/src/app.rs | 125 +++++++++++++++--- .../tests/routes.rs | 50 +++++++ crates/trusted-server-core/src/ec/provider.rs | 50 +++++++ 8 files changed, 529 insertions(+), 48 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9a371f805..f3c1403ba 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -16,6 +16,7 @@ use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -69,11 +70,18 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialise. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. The Axum dev server injects + // no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -153,13 +161,26 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the platform (a no-op on the local Axum dev server, so +/// jurisdiction stays Unknown there unless the request carries TCF consent), and +/// a geo lookup failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -168,10 +189,6 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -218,7 +235,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -454,7 +471,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -473,7 +490,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -640,3 +657,76 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// Builds application state directly, bypassing the composition root's + /// startup check, so the per-request behavior can be exercised with a + /// selection the adapter cannot supply. + fn state_with_uninjected_provider() -> AppState { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let state = state_with_uninjected_provider(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 5de96be92..cd96dfdcf 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -832,3 +832,53 @@ async fn first_party_proxy_rebuild_is_routed() { "/first-party/proxy-rebuild must be routed" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and the Axum dev server injects no vendor Edge Cookie provider, +/// so only the composition root can catch it. Without the startup check the +/// deployment would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 6ce0a5ee3..2c20d4470 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,6 +18,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -108,11 +109,18 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. This adapter injects no Edge + // Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -134,12 +142,25 @@ fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the Workers `cf` object when deployed. A malformed -/// consent string is logged and falls back to the default (fail-closed) context -/// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the Workers `cf` object when deployed, and a geo lookup +/// failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -148,10 +169,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -418,7 +435,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -523,7 +546,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -587,7 +610,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -625,3 +648,65 @@ fn build_router(state: &Arc) -> RouterService { router.build() } } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 93b0f9db9..b300e5b37 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -681,3 +681,53 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { "tsjs catch-all handler must not return 5xx: got {status}" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and this adapter injects no vendor Edge Cookie provider, so only +/// the composition root can catch it. Without the startup check the deployment +/// would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index bd999b56d..0b50a8d63 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -113,6 +113,7 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -180,11 +181,25 @@ pub(crate) fn load_settings_from_config_store( get_settings_from_config_store(&FastlyPlatformConfigStore, &store_name, &key) } +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialise. pub(crate) fn build_state_from_settings( settings: Settings, ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. This adapter injects no Edge + // Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + ensure_provider_available(&settings.ec, None)?; + let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index f24b5b717..294150959 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,6 +16,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -66,11 +67,18 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialise. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. This adapter injects no Edge + // Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -337,13 +345,26 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless -/// the request carries TCF consent. A malformed consent string is logged and -/// falls back to the default (fail-closed) context rather than being silently -/// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the +/// request carries TCF consent, and a geo lookup failure is logged and treated +/// as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -352,10 +373,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } fn admin_key_management_not_supported() -> Response { @@ -567,8 +584,13 @@ fn build_router(state: &Arc) -> RouterService { } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it - // closed for consented users. - let ec_context = build_ec_context(&s.settings, &services, &req); + // closed for consented users. When identity cannot be + // established at all, answer with an error rather than running + // the auction with no identity. + let ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -598,7 +620,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let ec_context = build_ec_context(&s.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than re-running the auction with no identity. + let ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -721,7 +749,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -854,8 +888,65 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2389ccebc..4b315ee14 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -976,3 +976,53 @@ async fn admin_deactivate_key_auth_fail_returns_401() { "admin/keys/deactivate without credentials must return 401" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and this adapter injects no vendor Edge Cookie provider, so only +/// the composition root can catch it. Without the startup check the deployment +/// would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index ec6eeef3c..b295921d4 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -327,6 +327,29 @@ pub fn build_provider( Ok(provider) } +/// Checks once, at startup, that this deployment can build the provider named +/// by the `[ec] provider` selector. +/// +/// The composition root calls this while it builds application state, passing +/// the same injected provider it will put into +/// [`RuntimeServices`](crate::platform::RuntimeServices) on every request. +/// [`build_provider`] reads no request data, so the answer is the same for +/// every request and a selection the adapter can never supply fails at startup +/// rather than on the first request. A stateless deployment (no selector, or +/// `"none"`) passes. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built from the services this deployment injects. +pub fn ensure_provider_available( + ec: &Ec, + injected: Option>, +) -> Result<(), Report> { + build_provider(ec, injected)?; + Ok(()) +} + /// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that /// [`build_provider`] returns. /// @@ -514,4 +537,31 @@ mod tests { "the error should name the selected provider, got: {err}" ); } + + #[test] + fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { + // A selection the adapter cannot supply is knowable without a request, + // so the composition root rejects it while application state is built. + let selected = Ec { + provider: Some("acme".to_owned()), + ..Ec::default() + }; + let err = ensure_provider_available(&selected, None) + .expect_err("an uninjected provider should fail the startup check"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + + // Statelessness is a supported deployment, spelled either way, and must + // never be turned into a startup error. + ensure_provider_available(&Ec::default(), None) + .expect("should allow a deployment that selects no provider"); + let explicit_none = Ec { + provider: Some("none".to_owned()), + ..Ec::default() + }; + ensure_provider_available(&explicit_none, None) + .expect("should allow the explicit `none` selection"); + } } From 61b7cf12e0798b7ae4905796cf52ca4922b8433c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:16:20 +0100 Subject: [PATCH 005/133] Hold the deprecated EC passphrase to the same rules as the new block `Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench) --- crates/trusted-server-core/src/settings.rs | 80 +++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 851690bc8..1fa5f79dc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -686,10 +686,17 @@ impl Ec { /// new location. A configuration carrying both forms is rejected so a /// half-edited file fails loudly instead of one form silently winning. /// + /// The deprecated key is held to the same passphrase rules as the new + /// `[ec.providers.hmac]` block. Derive validation runs before this + /// migration and the deprecated field carries no `#[validate]` attribute of + /// its own, so without the check here a short or empty passphrase in the old + /// location would start a deployment that the new location rejects. + /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when both the deprecated - /// key and any part of the provider configuration are present. + /// key and any part of the provider configuration are present, or when the + /// deprecated passphrase fails [`Self::validate_passphrase`]. pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { let Some(passphrase) = self.passphrase.take() else { return Ok(()); @@ -702,6 +709,15 @@ impl Ec { .to_owned(), })); } + Self::validate_passphrase(&passphrase).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] passphrase (deprecated) is invalid ({err}): use a random secret \ + of at least {} bytes, placed in [ec.providers.hmac]", + Self::MIN_PASSPHRASE_LENGTH, + ), + }) + })?; log::warn!( "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" @@ -5058,6 +5074,68 @@ mod tests { ); } + /// The crate test configuration with its `[ec]` section rewritten to the + /// deprecated single-passphrase form. + fn legacy_ec_settings_str(passphrase: &str) -> String { + let base = crate_test_settings_str(); + let (before, rest) = base + .split_once("[ec]") + .expect("should find the [ec] section in the test settings"); + let (_, after) = rest + .split_once("[request_signing]") + .expect("should find the [request_signing] section in the test settings"); + let legacy = + format!("{before}[ec]\npassphrase = \"{passphrase}\"\n\n[request_signing]{after}"); + assert!( + !legacy.contains("[ec.providers.hmac]"), + "the legacy configuration should carry no provider block" + ); + legacy + } + + #[test] + fn a_legacy_passphrase_is_held_to_the_passphrase_rules() { + // Derive validation runs before the migration and the deprecated field + // carries no `#[validate]` attribute, so the migration itself has to + // apply the passphrase rules. Without that, a value the new + // `[ec.providers.hmac]` block rejects would still start a deployment + // from the old location. + let short = Settings::from_toml(&legacy_ec_settings_str("short")) + .expect_err("a short legacy passphrase should be rejected"); + assert!( + format!("{short:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {short:?}" + ); + + let empty = Settings::from_toml(&legacy_ec_settings_str("")) + .expect_err("an empty legacy passphrase should be rejected"); + assert!( + format!("{empty:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {empty:?}" + ); + + let settings = + Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) + .expect("a legacy passphrase of adequate length should still start"); + assert_eq!( + settings.ec.provider.as_deref(), + Some("hmac"), + "an adequate legacy passphrase should still select the hmac provider" + ); + assert_eq!( + settings + .ec + .providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "an adequate legacy passphrase should still move into the hmac block" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( From 5b01f98ac084af5c6a2841b1dc5e39ccbeb6616b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:20:07 +0100 Subject: [PATCH 006/133] Reject unknown keys in the built-in HMAC provider block The provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench) --- crates/trusted-server-core/src/settings.rs | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1fa5f79dc..0d5c4792e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -790,8 +790,11 @@ impl EcProviders { /// Configuration for the built-in HMAC Edge Cookie provider. /// -/// Mapped from the `[ec.providers.hmac]` TOML block. +/// Mapped from the `[ec.providers.hmac]` TOML block. Unknown keys are +/// rejected, so a mistyped setting fails at startup instead of being accepted +/// silently and leaving the intended setting at its default. #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct HmacProviderConfig { /// Publisher passphrase used as the HMAC key for EC generation. #[validate(custom(function = Ec::validate_passphrase))] @@ -5233,6 +5236,27 @@ mod tests { ); } + #[test] + fn an_unknown_key_in_the_hmac_provider_block_is_rejected() { + // A mistyped key in a provider block used to be dropped silently, which + // leaves the setting the operator meant to change at its default. + let toml_str = crate_test_settings_str().replace( + "passphrase = \"test-secret-key-32-bytes-minimum\"", + "passphrase = \"test-secret-key-32-bytes-minimum\"\n typo_key = \"x\"", + ); + assert!( + toml_str.contains("typo_key"), + "the test configuration should carry the unknown key" + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("an unknown key in [ec.providers.hmac] should be rejected"); + assert!( + format!("{err:?}").contains("typo_key"), + "should name the unknown key: {err:?}" + ); + } + #[test] fn provider_none_is_explicit_stateless() { let ec = Ec { From 7424932d49b629ac20c99f5365431d61e721611b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:23:03 +0100 Subject: [PATCH 007/133] Stop rather than run stateless when the hmac block is missing `build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor) --- crates/trusted-server-core/src/ec/provider.rs | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b295921d4..452f20890 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -283,10 +283,11 @@ impl EdgeCookieProvider for HmacProvider { /// /// # Errors /// -/// None of the built-in constructions fail today. The `Result` is the seam for -/// a provider whose construction can fail (for example one requiring a host -/// service the deployment does not supply), so such a misconfiguration fails -/// loudly rather than minting a degraded identifier. +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built: `"hmac"` without an `[ec.providers.hmac]` block, or a vendor key +/// this deployment's adapter does not inject. Both fail loudly rather than +/// leaving the deployment running stateless under a selector that says +/// otherwise. pub fn build_provider( ec: &Ec, injected: Option>, @@ -297,11 +298,20 @@ pub fn build_provider( let provider: Option> = match key { // Explicit statelessness: the same meaning as omitting the selector. "none" => None, - "hmac" => ec - .providers - .hmac - .as_ref() - .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + // Settings validation rejects `hmac` with no block before this runs, so + // reaching here means the two checks have drifted apart. Stopping is + // the only safe answer: returning `Ok(None)` would run the deployment + // stateless under a selector that says it has an identity provider. + "hmac" => { + let config = ec.providers.hmac.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` is selected but has no \ + `[ec.providers.hmac]` configuration" + .to_owned(), + }) + })?; + Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) + } // Any other key names a vendor or host provider the adapter injects // through [`RuntimeServices`](crate::platform::RuntimeServices), the same // seam the device and geo providers use, so core never names a vendor. @@ -538,6 +548,25 @@ mod tests { ); } + #[test] + fn selecting_hmac_without_its_block_fails_loudly() { + // `Ec::validate_provider_selection` rejects this pair before settings + // reach the composition root, so the state is built directly here to + // reach the seam. If the two checks ever drift apart, `build_provider` + // must still stop rather than hand back a stateless deployment. + let ec = Ec { + provider: Some("hmac".to_owned()), + ..Ec::default() + }; + + let err = build_provider(&ec, None) + .expect_err("selecting hmac with no [ec.providers.hmac] block should error"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + #[test] fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { // A selection the adapter cannot supply is knowable without a request, From d6dc848130d2720a65bf42ff5f2a6d74a99ae60c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:26:03 +0100 Subject: [PATCH 008/133] Restore the missing line continuation in the mint rejection message The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick) --- crates/trusted-server-core/src/ec/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c9417a9fc..c29de2f94 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -441,7 +441,8 @@ impl EcContext { if !ec_id_has_only_allowed_chars(&ec_id) { return Err(Report::new(TrustedServerError::EdgeCookie { message: format!( - "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", + "Provider `{}` produced an identifier that is empty, over {} bytes, or \ + outside the cookie-safe alphabet", ec_provider.id(), cookies::MAX_EC_ID_LEN, ), From 45ec8292ee70743ef113743735dadaf5a77fc4a5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:29:30 +0100 Subject: [PATCH 009/133] Give EdgeCookieProvider its own doc comment back The paragraph written for the `EdgeCookieProvider` trait sat at the top of `ProviderCode`'s doc block, so rustdoc rendered it as part of that struct's documentation and the trait itself had no doc comment at all. A vendor implementer opening the trait saw nothing, and a reader of `ProviderCode` saw two subjects run together. The paragraph moves onto the trait and `ProviderCode` keeps only the registry text that belongs to it. The moved sentence was also stale: it said a provider returns `Ok(None)` from `generate`, but `generate` returns a `GeneratedEdgeCookie` and signals "no identifier this request" through its `id` field. The sentence now describes the actual return, with an intra-doc link to the field. `cargo doc --no-deps` reports no warning against either item. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:177 (nitpick) --- crates/trusted-server-core/src/ec/provider.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 452f20890..dfca75c7b 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -55,15 +55,6 @@ pub struct GeneratedEdgeCookie { pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } -/// A strategy for deriving an Edge Cookie identifier. -/// -/// Implementations are selected by configuration. A provider derives the -/// identifier at the edge in [`generate`](Self::generate), and the page -/// response sets the `ts-ec` cookie. -/// -/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it -/// cannot derive an identifier at the edge, so the request proceeds without an -/// Edge Cookie rather than failing. /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// @@ -174,6 +165,15 @@ pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String } } +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration. A provider derives the +/// identifier at the edge in [`generate`](Self::generate), and the page +/// response sets the `ts-ec` cookie. +/// +/// A provider that cannot derive an identifier at the edge returns a +/// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so +/// the request proceeds without an Edge Cookie rather than failing. pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Returns the stable identifier for this provider, used in configuration /// and logs. From c4c584c4f2b3f15d082533d660373ed6a191cdb5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:33:09 +0100 Subject: [PATCH 010/133] Delete the unused ec::get_ec_id helper `ec::get_ec_id` had no callers anywhere in the workspace, and this branch loosened its filter to accept any well-formed `{code}~` value with no ownership check against the selected provider. A future caller picking it up would adopt another provider's identifiers, which `EcContext` deliberately treats as absent. The no-callers claim was checked across every crate in the workspace (the four adapters, the CLI, core, the integration tests, openrtb) plus benches, tests and docs. The only matches are for a different, crate-private `edge_cookie::get_ec_id`, which reads the `x-ts-ec` header as well as the cookie and is what `proxy.rs` and the testlight integration call. Deleted rather than realigned, for two reasons. The workspace sets `publish = false`, so `trusted-server-core` is not distributed and nothing outside this repository depends on the symbol. And aligning the filter would mean calling `provider_owns_id`, which needs a `&dyn EdgeCookieProvider` that a function taking only `&Request` cannot obtain, so it would have meant changing the signature of a function with no callers. `EcContext::read_from_request` already performs the provider-aware read that production uses. `parse_ec_from_request`, `is_valid_ec_id` and `log_id` all keep other callers in the module, so nothing else becomes dead. The core README line that advertised the helper is removed in the same commit. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:137 (nitpick) --- crates/trusted-server-core/README.md | 2 +- crates/trusted-server-core/src/ec/mod.rs | 26 ------------------------ 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 3049a1115..53fd75774 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -51,7 +51,7 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr - The `ec/` module owns the EC identity subsystem: - `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`). - - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper. + - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`). - `ec/consent.rs` — EC-specific consent gating wrapper. - `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers. - `publisher.rs::handle_publisher_request` issues the `ts-ec` cookie when absent so the browser keeps the identifier on subsequent requests. diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c29de2f94..860dfe820 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -120,32 +120,6 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } -/// Gets an existing EC ID from the request. -/// -/// Attempts to retrieve an existing EC ID from the `ts-ec` cookie. -/// -/// Returns `None` if the cookie does not contain a valid EC ID. -/// -/// # Errors -/// -/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { - let parsed = parse_ec_from_request(req)?; - // Accept the coded form (any provider's `{code}~value` within the global - // identifier bounds) and the legacy bare HMAC form. Provider-aware - // ownership lives in `EcContext`; this helper only reads the string. - let ec_id = parsed - .cookie_ec - .filter(|v| match provider::split_provider_code(v) { - (Some(_), value) => !value.is_empty() && cookies::ec_id_has_only_allowed_chars(v), - (None, value) => is_valid_ec_id(value), - }); - if let Some(ref id) = ec_id { - log::trace!("Existing EC ID found: {}", log_id(id)); - } - Ok(ec_id) -} - /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during From 5203c2dbe60010a84fc5000608fb64fcf707a5d1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:37:21 +0100 Subject: [PATCH 011/133] Correct the provider module docs about when evidence arrives The `ec/provider.rs` module doc said a provider's constructor takes the services it needs, naming `RequestInfo` as the example, and its opening sentence was garbled where two half-sentences had been spliced together. `RequestInfo` is not a constructor argument. It is borrowed per call as the `request_info` parameter of `EdgeCookieProvider::generate`, so the first thing a vendor implementer read contradicted the trait they were about to implement. `evidence.rs` carried the same claim in its own words, that a constructor takes services as `Arc` supplied per request. Nothing in the workspace passes `RequestInfo` that way. Every use site is a `&dyn RequestInfo` argument. Both module docs now describe the real shape, which is construction once at startup from configuration or adapter injection, then borrowed request evidence on every call with nothing retained. The `evidence.rs` title changes to match, and its pointer to the borrowed view `BorrowedRequestInfo` is named alongside `OwnedRequestInfo`. Documentation only, no behavior change. `cargo doc --no-deps` reports no warning against either module. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:4 (nitpick) --- crates/trusted-server-core/src/ec/provider.rs | 27 ++++++++++++------- crates/trusted-server-core/src/evidence.rs | 20 +++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index dfca75c7b..29daa8001 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -1,15 +1,24 @@ //! Edge Cookie identity providers. //! -//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are -//! wired by dependency injection: a provider's constructor takes the services it -//! needs (for example [`RequestInfo`] for the client IP) -//! (the adapter, through [`build_provider`]) supplies instances per request. A -//! provider that needs a service the host does not supply cannot be built, so -//! the request stops rather than silently degrading. +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. The provider is +//! selected by configuration, with no default, and [`build_provider`] is the +//! composition root that builds the selected one. A built-in provider is +//! constructed from its `[ec.providers.]` block, and a vendor provider is +//! taken from the adapter that injected it. Construction happens once, while +//! application state is built, and reads no request data, so a selection this +//! deployment cannot satisfy fails at startup rather than leaving it running +//! without an identity. //! -//! The provider is selected by configuration, with no default. [`HmacProvider`] -//! is the built-in server-side implementation that derives the identifier from -//! the client IP using HMAC, the behavior Trusted Server has always shipped. +//! Request evidence reaches a provider at call time rather than at +//! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], +//! which carries the normalized client IP, the User-Agent and the request +//! headers, for the life of the call, alongside an [`IdentityInput`] holding +//! the request's gating context. A provider reads what it needs and retains +//! nothing, so no per-request snapshot is stored or cloned. +//! +//! [`HmacProvider`] is the built-in server-side implementation. It derives the +//! identifier from the client IP using HMAC over the configured passphrase, the +//! behavior Trusted Server has always shipped. use std::sync::Arc; diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 78e0d21ca..82eb408ea 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -1,14 +1,16 @@ -//! Service interfaces injected into providers. +//! Request evidence passed to providers. //! -//! Trusted Server wires providers by dependency injection. A provider's -//! constructor takes the services it needs as `Arc`, and the adapter -//! (the composition root) supplies instances per request. A provider that needs -//! a service the host does not supply cannot be built, so the request stops -//! rather than silently degrading. +//! A provider is constructed once, from its own configuration block or by the +//! adapter that injects it, and is handed the current request's evidence as a +//! borrowed `&dyn` view on every call, for example the `request_info` argument +//! of [`generate`](crate::ec::provider::EdgeCookieProvider::generate). Nothing +//! per-request is stored on the provider, so the same provider serves every +//! request. //! -//! These traits are the service interfaces. Request-scoped data outlives the -//! live request only when snapshotted, so an implementation owns its data where -//! needed ([`OwnedRequestInfo`] is the built-in owned snapshot). +//! These traits are those views. Request-scoped data outlives the live request +//! only when snapshotted, so an implementation owns its data where needed. +//! [`BorrowedRequestInfo`] is the borrowed view core builds on the request +//! path, and [`OwnedRequestInfo`] is the built-in owned snapshot. use http::HeaderMap; From e44ff38e0ac5d349cef8db5454facc29294eaaf3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:53:26 +0100 Subject: [PATCH 012/133] Replace the scattered EC provider key strings with a typed selector The keys `"hmac"` and `"none"` were spelled as bare string literals at four places: `Ec::validate_provider_selection`, `build_provider`, `provider_owns_id`'s `provider.id() == "hmac"` check, and a private `HMAC_PROVIDER_CODE` in `ec/generation.rs`. Nothing tied them together, so a fifth built-in provider would add a fifth spelling and a typo in any one of them would compile. `EcProviderSelection { None, Hmac, Vendor(String) }` now holds the vocabulary in `ec/provider.rs`, with `NONE_KEY` and `HMAC_KEY` as the only places those two words are written. Vendor keys are open-ended, so the catch-all `Vendor` variant takes any other key and `#[serde(from = "String", into = "String")]` gives the enum an infallible conversion in each direction rather than a hand-written visitor. `HMAC_PROVIDER_CODE` moves next to it as a `ProviderCode` const, built from `HMAC_KEY`, and `generation.rs` uses that instead of its own copy. `HmacProvider::id` and `HmacProvider::code` return the same two constants. `Ec::provider` becomes `Option`, so the two validation paths and `build_provider` match on variants rather than comparing strings, and `Option` still distinguishes an absent selector from an explicit `"none"` exactly as before. The configuration surface is unchanged. The selector reads and writes the same string, so an existing `trusted-server.toml` parses to the same choice and a config push writes the same key back. Tests: `the_selector_round_trips_through_serialization` parses `none`, `hmac` and an arbitrary vendor key from TOML, checks each maps to its variant, and checks each serializes back to the same string. `each_selection_builds_what_its_string_key_built_before` proves the three selections still build what they built before, which is nothing for `none`, the built-in provider with the built-in code for `hmac`, and the adapter-injected provider of that id for a vendor key. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs (refactor) --- .../trusted-server-core/src/ec/generation.rs | 10 +- crates/trusted-server-core/src/ec/mod.rs | 12 +- crates/trusted-server-core/src/ec/provider.rs | 223 ++++++++++++++++-- crates/trusted-server-core/src/settings.rs | 70 +++--- 4 files changed, 256 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 713e50474..99198c69b 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -10,13 +10,9 @@ use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; -use crate::ec::provider::{PROVIDER_CODE_SEPARATOR, split_provider_code}; +use crate::ec::provider::{HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR, split_provider_code}; use crate::error::TrustedServerError; -/// The registry code of the built-in HMAC provider, whose identifier shape -/// this module defines. -const HMAC_PROVIDER_CODE: &str = "hmac"; - type HmacSha256 = Hmac; const ALPHANUMERIC_CHARSET: &[u8] = @@ -156,7 +152,7 @@ pub fn ec_hash(ec_id: &str) -> &str { #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { let (code, bare) = match split_provider_code(ec_id) { - (Some(code), bare) if code == HMAC_PROVIDER_CODE => (Some(code), bare), + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => (Some(code), bare), (Some(_), _) => return ec_id.to_owned(), (None, bare) => (None, bare), }; @@ -203,7 +199,7 @@ pub fn is_valid_ec_hash(value: &str) -> bool { #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { let bare = match split_provider_code(value) { - (Some(code), bare) if code == HMAC_PROVIDER_CODE => bare, + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => bare, (Some(_), _) => return false, (None, bare) => bare, }; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 860dfe820..c9d9ab6d1 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -703,7 +703,7 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; - use crate::ec::provider::ProviderCode; + use crate::ec::provider::{EcProviderSelection, ProviderCode}; use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -838,7 +838,7 @@ mod tests { const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; let mut settings = create_test_settings(); - settings.ec.provider = Some("opaque".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); let cookie = format!("ts-ec={CODED_ID}"); let req = create_test_request(&[("cookie", &cookie)]); @@ -924,7 +924,7 @@ mod tests { let provider = Arc::new(EvidenceCapturingProvider::default()); let mut settings = create_test_settings(); - settings.ec.provider = Some("evidence".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); // A request carrying a query parameter and a (non-EC) cookie, with no // existing `ts-ec` cookie so the generate path runs. @@ -1001,7 +1001,7 @@ mod tests { const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; let mut settings = create_test_settings(); - settings.ec.provider = Some("server-opaque".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("server-opaque")); let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); let graph = KvIdentityGraph::in_memory("test-ec-store"); @@ -1071,7 +1071,7 @@ mod tests { use crate::platform::test_support::noop_services_with_ec_provider; let mut settings = create_test_settings(); - settings.ec.provider = Some("illegal".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("illegal")); let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); let req = create_test_request(&[]); let geo = non_regulated_geo(); @@ -1131,7 +1131,7 @@ mod tests { use crate::platform::test_support::noop_services_with_ec_provider; let mut settings = create_test_settings(); - settings.ec.provider = Some("canonical".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("canonical")); let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); let graph = KvIdentityGraph::in_memory("test-ec-store"); let req = create_test_request(&[]); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 29daa8001..b7b5a6931 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use error_stack::Report; +use serde::{Deserialize, Serialize}; use crate::consent::ConsentContext; use crate::error::TrustedServerError; @@ -32,6 +33,92 @@ use crate::settings::Ec; use super::generation; +/// The Edge Cookie identity provider a deployment has selected. +/// +/// Deserialized from the `[ec] provider` string, and serialized back to the +/// same string, so the configuration surface is unchanged. Vendor keys are +/// open-ended (a vendor crate names its own), so any key that is not a +/// built-in becomes [`Vendor`](Self::Vendor) rather than a parse failure, and +/// whether the deployment can actually supply it is decided by +/// [`build_provider`]. +/// +/// This is the one place the provider keys are spelled. Everything that needs +/// to ask which provider is selected matches on this rather than comparing +/// string literals. +#[derive(Debug, Clone, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[serde(from = "String", into = "String")] +pub enum EcProviderSelection { + /// Explicit statelessness, spelled `"none"`. The same meaning as omitting + /// the selector: no Edge Cookie is minted and no provider block may be + /// configured. + None, + + /// The built-in HMAC provider, spelled `"hmac"`, configured by + /// `[ec.providers.hmac]`. + Hmac, + + /// A vendor or host provider the adapter injects, named by its own key and + /// configured by the matching `[ec.providers.]` block. + Vendor(String), +} + +impl EcProviderSelection { + /// The configuration spelling of explicit statelessness. + pub const NONE_KEY: &'static str = "none"; + + /// The configuration spelling of the built-in HMAC provider, which is also + /// [`HmacProvider::id`]'s return value and [`HMAC_PROVIDER_CODE`]'s text. + pub const HMAC_KEY: &'static str = "hmac"; + + /// The configuration key this selection is written as. + #[must_use] + pub fn key(&self) -> &str { + match self { + Self::None => Self::NONE_KEY, + Self::Hmac => Self::HMAC_KEY, + Self::Vendor(key) => key, + } + } +} + +impl From<&str> for EcProviderSelection { + fn from(key: &str) -> Self { + match key { + EcProviderSelection::NONE_KEY => Self::None, + EcProviderSelection::HMAC_KEY => Self::Hmac, + other => Self::Vendor(other.to_owned()), + } + } +} + +impl From for EcProviderSelection { + fn from(key: String) -> Self { + match key.as_str() { + EcProviderSelection::NONE_KEY => Self::None, + EcProviderSelection::HMAC_KEY => Self::Hmac, + _ => Self::Vendor(key), + } + } +} + +impl From for String { + fn from(selection: EcProviderSelection) -> Self { + match selection { + EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), + EcProviderSelection::Hmac => EcProviderSelection::HMAC_KEY.to_owned(), + EcProviderSelection::Vendor(key) => key, + } + } +} + +/// The registry code of the built-in HMAC provider. +/// +/// The same text as [`EcProviderSelection::HMAC_KEY`], but a different role: +/// this is the `{code}~` namespace stamped on every identifier the built-in +/// provider mints, and it is what [`generation`] matches when it decides +/// whether an enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(EcProviderSelection::HMAC_KEY); + /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// /// Request data (client IP, User-Agent, headers, host signals) reaches a @@ -148,7 +235,9 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { match split_provider_code(full) { (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), - (None, value) => provider.id() == "hmac" && provider.accepts_id(value), + (None, value) => { + provider.id() == EcProviderSelection::HMAC_KEY && provider.accepts_id(value) + } } } @@ -261,11 +350,11 @@ impl HmacProvider { impl EdgeCookieProvider for HmacProvider { fn id(&self) -> &'static str { - "hmac" + EcProviderSelection::HMAC_KEY } fn code(&self) -> ProviderCode { - ProviderCode::new("hmac") + HMAC_PROVIDER_CODE } fn generate( @@ -301,17 +390,17 @@ pub fn build_provider( ec: &Ec, injected: Option>, ) -> Result>, Report> { - let Some(key) = ec.provider.as_deref() else { + let Some(selection) = ec.provider.as_ref() else { return Ok(None); }; - let provider: Option> = match key { + let provider: Option> = match selection { // Explicit statelessness: the same meaning as omitting the selector. - "none" => None, + EcProviderSelection::None => None, // Settings validation rejects `hmac` with no block before this runs, so // reaching here means the two checks have drifted apart. Stopping is // the only safe answer: returning `Ok(None)` would run the deployment // stateless under a selector that says it has an identity provider. - "hmac" => { + EcProviderSelection::Hmac => { let config = ec.providers.hmac.as_ref().ok_or_else(|| { Report::new(TrustedServerError::EdgeCookie { message: "Edge Cookie provider `hmac` is selected but has no \ @@ -321,21 +410,21 @@ pub fn build_provider( })?; Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) } - // Any other key names a vendor or host provider the adapter injects + // A vendor key names a vendor or host provider the adapter injects // through [`RuntimeServices`](crate::platform::RuntimeServices), the same // seam the device and geo providers use, so core never names a vendor. // The injected provider is used when its own id matches the selected key, // and its `[ec.providers.]` block is read by the adapter that built // it. A selected key with no matching injected provider is a deployment // error: fail loudly rather than silently running stateless. - other => { + EcProviderSelection::Vendor(key) => { let provider = injected - .filter(|provider| provider.id() == other) + .filter(|provider| provider.id() == key) .map(|provider| Box::new(SharedProvider(provider)) as _); if provider.is_none() { return Err(Report::new(TrustedServerError::EdgeCookie { message: format!( - "Edge Cookie provider `{other}` is selected but this deployment's \ + "Edge Cookie provider `{key}` is selected but this deployment's \ adapter does not provide it" ), })); @@ -408,6 +497,7 @@ impl EdgeCookieProvider for SharedProvider { #[cfg(test)] mod tests { use super::*; + use crate::settings::{EcProviders, HmacProviderConfig}; #[test] fn split_provider_code_separates_coded_and_legacy_forms() { @@ -438,6 +528,109 @@ mod tests { ); } + /// A stand-in for a vendor provider an adapter injects. + #[derive(Debug)] + struct VendorProvider; + + impl EdgeCookieProvider for VendorProvider { + fn id(&self) -> &'static str { + "acme" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ac") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn the_selector_round_trips_through_serialization() { + // The typed selector must not change the configuration surface. The + // same TOML has to parse to the same choice, and serializing has to + // write the same key back, so an existing operator configuration keeps + // working and a config push does not rewrite the selector. + for (key, expected) in [ + (EcProviderSelection::NONE_KEY, EcProviderSelection::None), + (EcProviderSelection::HMAC_KEY, EcProviderSelection::Hmac), + ("acme", EcProviderSelection::Vendor("acme".to_owned())), + ] { + let ec: Ec = toml::from_str(&format!("provider = \"{key}\"")) + .expect("should parse the [ec] section"); + assert_eq!( + ec.provider.as_ref(), + Some(&expected), + "`{key}` should select the provider it names" + ); + + let written = toml::to_string(&ec).expect("should serialize the [ec] section"); + assert!( + written.contains(&format!("provider = \"{key}\"")), + "`{key}` should be written back unchanged, got: {written}" + ); + } + } + + #[test] + fn each_selection_builds_what_its_string_key_built_before() { + // `none` is stateless, exactly as omitting the selector is. + let none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + assert!( + build_provider(&none, None) + .expect("explicit statelessness should build") + .is_none(), + "`none` should select no provider" + ); + + // `hmac` with its block builds the built-in provider. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac = Ec { + provider: Some(EcProviderSelection::Hmac), + providers, + ..Ec::default() + }; + let built = build_provider(&hmac, None) + .expect("the hmac selection should build") + .expect("the hmac selection should yield a provider"); + assert_eq!( + built.id(), + EcProviderSelection::HMAC_KEY, + "`hmac` should select the built-in provider" + ); + assert_eq!( + built.code(), + HMAC_PROVIDER_CODE, + "the built-in provider should carry the built-in code" + ); + + // An arbitrary vendor key selects the provider the adapter injected + // under that same key. + let vendor = Ec { + provider: Some(EcProviderSelection::Vendor("acme".to_owned())), + ..Ec::default() + }; + let built = build_provider(&vendor, Some(Arc::new(VendorProvider))) + .expect("the vendor selection should build") + .expect("the vendor selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "a vendor key should select the injected provider of that id" + ); + } + #[test] fn provider_ownership_follows_the_code() { let provider = HmacProvider::new(test_passphrase()); @@ -545,7 +738,7 @@ mod tests { #[test] fn a_selected_but_uninjected_vendor_provider_fails_loudly() { let ec = Ec { - provider: Some("acme".to_owned()), + provider: Some(EcProviderSelection::from("acme")), ..Ec::default() }; @@ -564,7 +757,7 @@ mod tests { // reach the seam. If the two checks ever drift apart, `build_provider` // must still stop rather than hand back a stateless deployment. let ec = Ec { - provider: Some("hmac".to_owned()), + provider: Some(EcProviderSelection::Hmac), ..Ec::default() }; @@ -581,7 +774,7 @@ mod tests { // A selection the adapter cannot supply is knowable without a request, // so the composition root rejects it while application state is built. let selected = Ec { - provider: Some("acme".to_owned()), + provider: Some(EcProviderSelection::from("acme")), ..Ec::default() }; let err = ensure_provider_available(&selected, None) @@ -596,7 +789,7 @@ mod tests { ensure_provider_available(&Ec::default(), None) .expect("should allow a deployment that selects no provider"); let explicit_none = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), ..Ec::default() }; ensure_provider_available(&explicit_none, None) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0d5c4792e..a39a8ff87 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,6 +20,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; +use crate::ec::provider::EcProviderSelection; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -486,8 +487,12 @@ pub struct Ec { /// explicit `"none"` spells the same choice. Selecting a provider whose /// block is missing is rejected at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). + /// + /// Typed as [`EcProviderSelection`], which reads and writes the same + /// string, so every check that asks which provider is selected matches on + /// one vocabulary rather than comparing string literals. #[serde(default)] - pub provider: Option, + pub provider: Option, /// Deprecated location of the HMAC passphrase, read so a configuration /// written for the previous release still starts. @@ -610,7 +615,7 @@ impl Ec { /// Returns [`TrustedServerError::Configuration`] when the selected provider /// key is unknown or its `[ec.providers.]` block is absent. pub fn validate_provider_selection(&self) -> Result<(), Report> { - let Some(key) = self.provider.as_deref() else { + let Some(selection) = self.provider.as_ref() else { if !self.providers.is_empty() { return Err(Report::new(TrustedServerError::Configuration { message: "[ec.providers.*] blocks are configured but no [ec] provider is \ @@ -622,27 +627,30 @@ impl Ec { return Ok(()); }; - // `"none"` is explicit statelessness: the same meaning as omitting - // the selector, spelled out. It is subject to the same rule that no - // provider blocks may be left configured. - if key == "none" { - if !self.providers.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "[ec] provider = \"none\" selects stateless operation, but \ - [ec.providers.*] blocks are configured. Remove the blocks, or \ - select the provider they configure" - .to_owned(), - })); + let (key, configured) = match selection { + // `"none"` is explicit statelessness: the same meaning as omitting + // the selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + EcProviderSelection::None => { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + } + EcProviderSelection::Hmac => { + (EcProviderSelection::HMAC_KEY, self.providers.hmac.is_some()) } - return Ok(()); - } - - let configured = match key { - "hmac" => self.providers.hmac.is_some(), // A vendor or host provider the adapter injects is configured when // its `[ec.providers.]` block is present. The adapter validates // the block's own contents when it builds the provider. - other => self.providers.has_vendor(other), + EcProviderSelection::Vendor(vendor_key) => { + (vendor_key.as_str(), self.providers.has_vendor(vendor_key)) + } }; if !configured { @@ -657,8 +665,8 @@ impl Ec { // block is almost always a mistake (a mistyped selector or a stale // block), and accepting it silently invites configuration drift. let mut unreferenced: Vec = Vec::new(); - if self.providers.hmac.is_some() && key != "hmac" { - unreferenced.push("hmac".to_owned()); + if self.providers.hmac.is_some() && !matches!(selection, EcProviderSelection::Hmac) { + unreferenced.push(EcProviderSelection::HMAC_KEY.to_owned()); } for vendor_key in self.providers.vendor_keys() { if vendor_key != key { @@ -722,7 +730,7 @@ impl Ec { "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" ); - self.provider = Some("hmac".to_owned()); + self.provider = Some(EcProviderSelection::Hmac); self.providers.hmac = Some(HmacProviderConfig { passphrase }); Ok(()) } @@ -4608,8 +4616,8 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.provider.as_deref(), - Some("hmac"), + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "test settings should select the hmac EC provider" ); let Some(hmac) = &settings.ec.providers.hmac else { @@ -5057,8 +5065,8 @@ mod tests { ec.migrate_legacy_ec_layout() .expect("should migrate the deprecated form"); assert_eq!( - ec.provider.as_deref(), - Some("hmac"), + ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "the deprecated passphrase should select the hmac provider" ); assert_eq!( @@ -5121,8 +5129,8 @@ mod tests { Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) .expect("a legacy passphrase of adequate length should still start"); assert_eq!( - settings.ec.provider.as_deref(), - Some("hmac"), + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "an adequate legacy passphrase should still select the hmac provider" ); assert_eq!( @@ -5220,7 +5228,7 @@ mod tests { fn legacy_passphrase_alongside_provider_config_is_rejected() { let mut ec = Ec { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), - provider: Some("hmac".to_owned()), + provider: Some(EcProviderSelection::Hmac), ..Ec::default() }; let err = ec @@ -5260,7 +5268,7 @@ mod tests { #[test] fn provider_none_is_explicit_stateless() { let ec = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), ..Ec::default() }; ec.validate_provider_selection() @@ -5270,7 +5278,7 @@ mod tests { #[test] fn provider_none_with_configured_blocks_is_rejected() { let ec = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), providers: EcProviders { hmac: Some(HmacProviderConfig { passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), From afdb7be20cf74d2d489c58ed3a9296d0b3ea14e8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:18:29 +0100 Subject: [PATCH 013/133] Reserve core's own response surface against provider effects A provider's response headers were inserted into the outbound response without any check on what they set. A provider could return `Set-Cookie: ts-ec=...`, including on a request where it minted no identifier at all, and so write the managed identity cookie without going through core's identifier validation or its requirement that a minted identifier have an identity-graph row. It could also overwrite an `x-ts-*` header or a framing header. Core now defends by reserving its own namespace rather than banning `Set-Cookie`, because providers legitimately need cookies of their own. `reserved_response_effect` in `ec/provider.rs` classifies one header and rejects three things: a `Set-Cookie` naming a cookie in the `ts-` prefix core manages (`ts-ec`, `ts-eids`, `ts-tester`), a header in the `x-ts-` namespace core emits and strips, and a message framing or hop-by-hop header (RFC 7230 6.1 plus `content-length`, the same set each adapter's `is_hop_by_hop_response_header` uses). Everything else, a provider's own cookie included, passes through unchanged. The cookie name is read from the raw header bytes so a value that is not valid UTF-8 cannot smuggle a managed name past the check. A rejected effect fails the request rather than being dropped with a log. The check sits in `EcContext::generate_with_provider`, the only place provider headers are captured, next to the identifier-bounds check that already fails the request when a provider mints outside the cookie-safe alphabet. Both are the same kind of fault, a provider breaking its contract, and this branch has already decided that identity problems stop the request rather than serving without identity. Finalization cannot fail a request in any case, since it returns no result. Tests cover the classifier directly (managed cookie, reserved header, framing header, a non-UTF-8 `Set-Cookie`, and the allowed cases), and cover both halves through the organic generate path: a provider setting `ts-ec` with no identifier fails the request, and a provider setting its own `acme-evidence` cookie mints normally and has that cookie reach the response alongside core's own `ts-ec`. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/finalize.rs:57 (P2) --- crates/trusted-server-core/src/ec/finalize.rs | 6 +- crates/trusted-server-core/src/ec/mod.rs | 177 +++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 191 ++++++++++++++++++ 3 files changed, 371 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index d09d8097a..b27b700a3 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -52,7 +52,11 @@ pub fn ec_finalize_response( ) { // Apply any response headers the active provider asked for during // generation (for example to request more client evidence). This is empty - // unless a provider produced headers, so it is safe on every path. + // unless a provider produced headers, so it is safe on every path. Each + // one was checked against core's reserved response surface at capture + // time in `EcContext::generate_with_provider`, so nothing here can set a + // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop + // header. for (name, value) in ec_context.response_headers() { response.headers_mut().insert(name, value.clone()); } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c9d9ab6d1..c530b0476 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -371,8 +371,10 @@ impl EcContext { /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is - /// unavailable, the provider fails to derive an identifier, or persisting a - /// generated identifier to the KV identity graph fails. + /// unavailable, the provider fails to derive an identifier, the provider + /// asks for a response header inside core's reserved surface (see + /// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)), + /// or persisting a generated identifier to the KV identity graph fails. fn generate_with_provider( &mut self, ec_provider: &dyn EdgeCookieProvider, @@ -393,6 +395,25 @@ impl EcContext { ) .with_request_target(&self.request_path, &self.request_query); let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Check every response header the provider asked for against core's + // reserved surface before any of them are kept. A provider may set its + // own cookies and headers, but not a managed `ts-` cookie, a header in + // the `x-ts-` namespace, or a framing or hop-by-hop header. Rejection + // fails the request, matching the identifier-bounds rejection below: + // without it a provider could write `ts-ec` itself and bypass the + // identifier validation and identity-graph row this function enforces. + // Checked before the identifier is read, because a provider can return + // headers with no identifier at all. + for (name, value) in &generated.response_headers { + if let Some(effect) = provider::reserved_response_effect(name, value) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` returned a response header `{name}` that {effect}", + ec_provider.id(), + ), + })); + } + } // Capture any response headers the provider asked for, even when it // produced no identifier (for example while it still needs more client // evidence). EC finalization applies them to the response. @@ -1092,6 +1113,158 @@ mod tests { ); } + /// A provider that returns a caller-chosen response header and no + /// identifier, so a test can drive one provider response effect at a time + /// through the organic generate path. + #[derive(Debug)] + struct HeaderSettingProvider { + name: &'static str, + value: &'static str, + mint: bool, + } + + impl EdgeCookieProvider for HeaderSettingProvider { + fn id(&self) -> &'static str { + "header-setting" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0hs") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.mint.then(|| "provider-value".to_owned()), + response_headers: vec![( + http::HeaderName::from_bytes(self.name.as_bytes()) + .expect("should parse header name"), + http::HeaderValue::from_static(self.value), + )], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + fn generate_with_header_setting_provider( + provider: HeaderSettingProvider, + graph: Option<&KvIdentityGraph>, + ) -> (Settings, Result>) { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("header-setting")); + let services = noop_services_with_ec_provider(Arc::new(provider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + let outcome = ec.generate_if_needed(&settings, graph).map(|()| ec); + (settings, outcome) + } + + #[test] + fn generate_rejects_a_provider_effect_inside_the_reserved_response_surface() { + // A provider that sets the managed `ts-ec` cookie would bypass core's + // identifier validation and its identity-graph row entirely, so the + // request fails rather than the effect being quietly dropped. The + // provider mints no identifier here, which is exactly the case the + // cookie write would otherwise slip through. + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "ts-ec=forged-value; Path=/", + mint: false, + }, + None, + ); + + let err = outcome.expect_err("a managed cookie effect should fail the request"); + assert!( + err.to_string().contains("header-setting"), + "the error should name the provider, got: {err}" + ); + + // The same for the reserved header namespace and for message framing. + for (name, value) in [("x-ts-ec", "forged"), ("transfer-encoding", "chunked")] { + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name, + value, + mint: false, + }, + None, + ); + assert!( + outcome.is_err(), + "`{name}` is reserved and should fail the request" + ); + } + } + + #[test] + fn generate_applies_a_provider_owned_cookie_to_the_response() { + // The other half of the rule: a provider's own cookie is not core's, so + // it survives generation and reaches the browser response unchanged, + // alongside the managed `ts-ec` cookie core writes itself. + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "acme-evidence=abc123; Path=/; Secure", + mint: true, + }, + Some(&graph), + ); + let ec = outcome.expect("a provider-owned cookie should not fail the request"); + assert_eq!( + ec.ec_value(), + Some("t0hs~provider-value"), + "the identifier should still be committed" + ); + + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + &settings, + &ec, + Some(&graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=abc123")), + "the provider's own cookie should reach the response, got: {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie should still be written, got: {cookies:?}" + ); + } + /// A provider whose identifier normalizes to a distinct canonical form, to /// prove the identity graph is keyed by the canonical form. #[derive(Debug)] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b7b5a6931..bc3f0a4a9 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -148,9 +148,117 @@ pub struct GeneratedEdgeCookie { /// Response headers the provider needs set on the outbound response, for /// example to request additional client evidence on later requests. Empty /// for providers that set no headers, such as [`HmacProvider`]. + /// + /// Core checks every header here against its own reserved response surface + /// (see [`reserved_response_effect`]) before it is applied, so a provider + /// may set its own cookies and headers but cannot reach into the surface + /// core manages. pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } +/// The cookie-name namespace Trusted Server manages. +/// +/// Every cookie core writes or reads as part of its own behavior is named +/// `ts-` (`ts-ec` in [`COOKIE_TS_EC`](crate::constants::COOKIE_TS_EC), +/// `ts-eids` in [`COOKIE_TS_EIDS`](crate::constants::COOKIE_TS_EIDS), and +/// `ts-tester` in [`COOKIE_TS_TESTER`](crate::constants::COOKIE_TS_TESTER)), so +/// core defends the whole prefix rather than a list that a new managed cookie +/// would silently outgrow. `sharedId` is deliberately not reserved: core only +/// reads it, and it belongs to the page's own identity stack. +const MANAGED_COOKIE_NAME_PREFIX: &[u8] = b"ts-"; + +/// The response-header namespace Trusted Server reserves for itself. +/// +/// Covers the fixed EC output headers and the per-partner +/// `x-ts-` headers, which is why the prefix is reserved rather +/// than the four names in +/// [`INTERNAL_HEADERS`](crate::constants::INTERNAL_HEADERS). +const RESERVED_RESPONSE_HEADER_PREFIX: &str = "x-ts-"; + +/// Response headers that frame an HTTP message or are hop-by-hop. +/// +/// The hop-by-hop set is RFC 7230 §6.1, matching each adapter's +/// `is_hop_by_hop_response_header`, plus `content-length`, which frames the +/// body the adapter is about to write. A provider that set any of these would +/// be rewriting the response envelope rather than adding evidence to it. +const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Why one provider response header falls inside core's reserved surface. +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +pub enum ReservedResponseEffect { + /// A `Set-Cookie` naming a cookie in the `ts-` namespace core manages. + #[display("sets a cookie in the `ts-` namespace Trusted Server manages")] + ManagedCookie, + + /// A header in the `x-ts-` namespace core emits and strips. + #[display("sets a header in the reserved `x-ts-` namespace")] + ReservedHeader, + + /// A message framing or hop-by-hop header. + #[display("sets a message framing or hop-by-hop header")] + FramingHeader, +} + +/// The cookie name in a `Set-Cookie` value, as raw bytes. +/// +/// Reads the bytes rather than a `&str` so a value that is not valid UTF-8 +/// cannot smuggle a managed cookie name past the check. +fn set_cookie_name(value: &[u8]) -> &[u8] { + let pair_end = value.iter().position(|b| *b == b';').unwrap_or(value.len()); + let pair = &value[..pair_end]; + let name_end = pair.iter().position(|b| *b == b'=').unwrap_or(pair.len()); + pair[..name_end].trim_ascii() +} + +/// Classifies one provider response header against core's reserved surface. +/// +/// Returns `Some` when the header would reach into what core manages, and +/// `None` for everything else, including a provider's own cookie. Providers +/// legitimately need to set cookies of their own (an evidence cookie for a +/// later request, for example), so the rule reserves core's namespace rather +/// than banning `Set-Cookie` outright. +/// +/// A rejected effect fails the request rather than being dropped, because a +/// provider reaching into the reserved surface has broken its contract in the +/// same way as one minting an identifier outside the cookie-safe alphabet, and +/// that already fails the request. Serving the response instead would let a +/// provider set `ts-ec` directly, bypassing core's identifier validation and +/// its requirement that a minted identifier have an identity-graph row. +#[must_use] +pub fn reserved_response_effect( + name: &http::HeaderName, + value: &http::HeaderValue, +) -> Option { + let lower = name.as_str(); + if lower == http::header::SET_COOKIE.as_str() { + let cookie_name = set_cookie_name(value.as_bytes()); + if cookie_name.len() >= MANAGED_COOKIE_NAME_PREFIX.len() + && cookie_name[..MANAGED_COOKIE_NAME_PREFIX.len()] + .eq_ignore_ascii_case(MANAGED_COOKIE_NAME_PREFIX) + { + return Some(ReservedResponseEffect::ManagedCookie); + } + return None; + } + if lower.starts_with(RESERVED_RESPONSE_HEADER_PREFIX) { + return Some(ReservedResponseEffect::ReservedHeader); + } + if FRAMING_OR_HOP_BY_HOP_HEADERS.contains(&lower) { + return Some(ReservedResponseEffect::FramingHeader); + } + None +} + /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// @@ -528,6 +636,89 @@ mod tests { ); } + fn header(name: &str, value: &str) -> (http::HeaderName, http::HeaderValue) { + ( + http::HeaderName::from_bytes(name.as_bytes()).expect("should parse header name"), + http::HeaderValue::from_str(value).expect("should parse header value"), + ) + } + + #[test] + fn reserved_response_effect_rejects_the_namespace_core_manages() { + for (name, value, expected) in [ + ( + "set-cookie", + "ts-ec=hmac~deadbeef.abc123; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ( + "Set-Cookie", + " TS-EIDS=x; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ("x-ts-ec", "spoofed", ReservedResponseEffect::ReservedHeader), + ( + "X-TS-partner.example.com", + "uid", + ReservedResponseEffect::ReservedHeader, + ), + ("content-length", "0", ReservedResponseEffect::FramingHeader), + ( + "Transfer-Encoding", + "chunked", + ReservedResponseEffect::FramingHeader, + ), + ("connection", "close", ReservedResponseEffect::FramingHeader), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + Some(expected), + "`{name}` should be reserved" + ); + } + } + + #[test] + fn reserved_response_effect_allows_provider_owned_effects() { + for (name, value) in [ + ("set-cookie", "acme-evidence=abc; Path=/; Secure"), + ("set-cookie", "sharedId=abc"), + ("accept-ch", "Sec-CH-UA-Full-Version-List"), + ("x-acme-probe", "1"), + ("vary", "Sec-CH-UA"), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + None, + "`{name}` is the provider's own and should be allowed" + ); + } + } + + #[test] + fn reserved_response_effect_reads_a_non_utf8_set_cookie_as_bytes() { + // A `Set-Cookie` carrying a byte above 127 cannot be read as a string, + // so the cookie name is matched on raw bytes. Reading it as UTF-8 and + // giving up on failure would let this value through. + let name = http::header::SET_COOKIE; + let mut bytes = b"ts-ec=value".to_vec(); + bytes.push(0xff); + bytes.extend_from_slice(b"; Path=/"); + let value = + http::HeaderValue::from_bytes(&bytes).expect("should build a non-utf8 header value"); + assert!( + value.to_str().is_err(), + "the test value should not be readable as UTF-8" + ); + assert_eq!( + reserved_response_effect(&name, &value), + Some(ReservedResponseEffect::ManagedCookie), + "a non-UTF-8 Set-Cookie should still be matched on its cookie name" + ); + } + /// A stand-in for a vendor provider an adapter injects. #[derive(Debug)] struct VendorProvider; From e90b4711d61b32f1add1e8ddfe3a40b5d29bba39 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:38:42 +0100 Subject: [PATCH 014/133] Dispatch partner-path identifier checks by provider code `is_valid_ec_id` is the built-in HMAC grammar and rejects every other provider code, yet pull sync, batch sync, and the admin lookup all called it directly. A deployment running a non-HMAC provider therefore minted and read identifiers on the organic path that these three paths skipped or rejected. PR #1044's `hs00~` host-signal provider makes that concrete. The check is now split in two, in `AcceptedProviders` in `ec/provider.rs`. The global cookie bounds, the length cap and the cookie-safe alphabet in `ec_id_has_only_allowed_chars`, apply to every identifier whoever minted it. The rest is dispatched by the `{code}~` prefix to the provider that owns that code, which canonicalizes its own value part and decides whether the canonical form is one of its own. Dispatch is on the code alone, before any provider inspects a value, so an identifier a partner echoed back in a different case still reaches its own provider to be canonicalized rather than being rejected first. KV normalization goes the same way through `canonical_kv_key`, so a row is always keyed by the owning provider's canonical form. A code no configured provider reads is rejected. The set of accepted providers is the deployment's active provider. `legacy_providers`, the design's list of readers that never mint, is not implemented on this branch (the key is rejected as unknown, see section 6.1 of the pluggable-providers design), so `AcceptedProviders::active` fills the reader list with the one active provider. The list is the seam: configured legacy readers are pushed alongside it and neither `accepts` nor `canonical_kv_key` changes. With no provider selected at all the deployment is stateless, and the built-in grammar stays the fallback, matching what `EcContext::accepts_id` has always done. Wiring: `EcContext::accepts_id` now goes through `AcceptedProviders`, so pull sync validates through it; `handle_batch_sync` and `handle_admin_ec_lookup` take the selected provider, which the Fastly adapter builds at both call sites. Tests cover a non-HMAC identifier accepted in pull sync, batch sync, and the admin lookup; a code neither active nor configured rejected in batch sync and the admin lookup, including one in the built-in HMAC shape; KV normalization dispatched to the owning provider (the built-in lowercases its hash segment, an opaque provider keys verbatim); and the global bounds rejecting before any provider is consulted. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/generation.rs:207 (P2) --- .../trusted-server-adapter-fastly/src/app.rs | 14 +- crates/trusted-server-core/src/ec/admin.rs | 104 +++++++-- .../trusted-server-core/src/ec/batch_sync.rs | 207 ++++++++++++++++-- .../trusted-server-core/src/ec/generation.rs | 17 +- crates/trusted-server-core/src/ec/mod.rs | 20 +- crates/trusted-server-core/src/ec/provider.rs | 145 ++++++++++++ .../trusted-server-core/src/ec/pull_sync.rs | 92 +++++++- 7 files changed, 540 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 0b50a8d63..c64ebfe6e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -113,6 +113,7 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::provider::build_provider; use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -570,7 +571,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected provider decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let provider = build_provider(&state.settings.ec, services.ec_provider())?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, provider.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -734,7 +740,11 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> let result = crate::require_identity_graph(&state.settings).and_then(|kv| { let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) + // A partner echoes back an identifier the deployment's own provider + // minted, so validation and KV normalization are dispatched through + // that provider rather than the built-in HMAC grammar. + let provider = build_provider(&state.settings.ec, services.ec_provider())?; + handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6cf4b3921..bb15b6892 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -32,7 +32,6 @@ use crate::error::TrustedServerError; use crate::openrtb::Eid; use super::eids::{resolve_partner_ids, to_eids}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::kv_backend::EcKvLookup; use super::kv_types::{KvEntry, KvMetadata}; @@ -40,6 +39,7 @@ use super::log_id; use super::prebid_eids::{ analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, }; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::registry::PartnerRegistry; /// Route prefix shared by the cookie-based and explicit-ID lookup routes. @@ -276,13 +276,14 @@ struct SkippedPartnerId { pub fn handle_admin_ec_lookup( kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, + provider: Option<&dyn EdgeCookieProvider>, req: &Request, ) -> Result, Report> { let Some(kv) = kv else { return Ok(admin_ec_lookup_not_supported()); }; - let ec_id = match requested_ec_id(req) { + let ec_id = match requested_ec_id(req, &AcceptedProviders::active(provider)) { Ok(ec_id) => ec_id, Err(response) => return Ok(*response), }; @@ -342,9 +343,17 @@ fn cookie_ec_id(req: &Request) -> Result) -> Result>> { +fn requested_ec_id( + req: &Request, + accepted_providers: &AcceptedProviders<'_>, +) -> Result>> { let remainder = req .uri() .path() @@ -358,10 +367,10 @@ fn requested_ec_id(req: &Request) -> Result &'static str { + "opaque" + } + + fn code(&self) -> super::super::provider::ProviderCode { + super::super::provider::ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &super::super::provider::IdentityInput<'_>, + ) -> Result> + { + Ok(super::super::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + #[test] fn requested_ec_id_accepts_the_hmac_envelope() { let coded = format!("hmac~{}", test_ec_id()); let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); - let ec_id = requested_ec_id(&request) + let ec_id = requested_ec_id(&request, &AcceptedProviders::active(None)) .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); assert_eq!(ec_id, coded, "should look up the identifier as given"); } + + #[test] + fn requested_ec_id_accepts_the_active_non_hmac_provider_and_rejects_others() { + // The diagnostic must be usable on a deployment whose provider is not + // the built-in HMAC one. Before the dispatch every non-`hmac` code was + // a 400, so an operator could not look up the identifier in the very + // cookie the browser was carrying. + let accepted = AcceptedProviders::active(Some(&OpaqueProvider)); + + let opaque = "t0op~Opaque_Value_MixedCase"; + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); + let ec_id = requested_ec_id(&request, &accepted) + .unwrap_or_else(|_| panic!("should accept the active provider's identifier")); + assert_eq!(ec_id, opaque, "should look up the identifier as given"); + + // A code no configured provider reads stays a 400, even in the built-in + // HMAC shape, so one deployment cannot inspect another's identifiers. + let foreign = format!("t0zz~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{foreign}")); + let response = requested_ec_id(&request, &accepted) + .expect_err("an unread provider code should be rejected"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an unread provider code should be a 400" + ); + } } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 57b4f9ed9..e75c02ddb 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -20,9 +20,9 @@ use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; use super::auth::authenticate_bearer; -use super::generation::{is_valid_ec_id, normalize_ec_id_for_kv}; use super::kv::{KvIdentityGraph, UpsertResult}; use super::log_id; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; @@ -101,15 +101,17 @@ pub fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, req) + handle_batch_sync_with_writer(kv, registry, rate_limiter, provider, req) } fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { // 1. Authenticate @@ -153,7 +155,12 @@ fn handle_batch_sync_with_writer( } // 4. Process mappings with per-item validation and rejection reasons. - let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings); + let (accepted, errors) = process_mappings( + writer, + &partner.source_domain, + &body.mappings, + &AcceptedProviders::active(provider), + ); let rejected = errors.len(); let status = if rejected > 0 { @@ -183,19 +190,24 @@ fn process_mappings( writer: &dyn BatchSyncWriter, partner_id: &str, mappings: &[SyncMapping], + accepted_providers: &AcceptedProviders<'_>, ) -> (usize, Vec) { let mut accepted: usize = 0; let mut errors = Vec::new(); for (idx, mapping) in mappings.iter().enumerate() { - let ec_id = normalize_ec_id_for_kv(&mapping.ec_id); - if !is_valid_ec_id(&ec_id) { + // The global cookie bounds, then the provider that owns the + // identifier's code, which canonicalizes its own value part and decides + // whether the canonical form is one of its own. A partner echoing back + // an identifier a non-HMAC provider minted is accepted here; an + // identifier under a code this deployment does not read is not. + let Some(ec_id) = accepted_providers.canonical_kv_key(&mapping.ec_id) else { errors.push(MappingError { index: idx, reason: REASON_INVALID_EC_ID, }); continue; - } + }; if mapping.partner_uid.trim().is_empty() || mapping.partner_uid.len() > MAX_UID_LENGTH { errors.push(MappingError { @@ -266,17 +278,47 @@ mod tests { use super::*; use std::collections::VecDeque; + use crate::ec::provider::{HmacProvider, IdentityInput, ProviderCode}; use crate::error::TrustedServerError; + use crate::evidence::RequestInfo; use crate::redacted::Redacted; use crate::settings::EcPartner; - // EC ID validation tests are in generation.rs (is_valid_ec_id). - // Verify the import works here with a basic smoke test. - #[test] - fn is_valid_ec_id_smoke_test() { - let valid = format!("{}.ABC123", "a".repeat(64)); - assert!(is_valid_ec_id(&valid)); - assert!(!is_valid_ec_id(&"a".repeat(64))); + /// The built-in provider, standing in for a deployment that selected it. + fn hmac_provider() -> HmacProvider { + HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())) + } + + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } } struct MockRateLimiter { @@ -421,7 +463,7 @@ mod tests { .body(EdgeBody::from("not-json")) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -447,7 +489,7 @@ mod tests { .body(EdgeBody::from(oversized_body)) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -466,7 +508,13 @@ mod tests { mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count successful writes as accepted"); assert_eq!(errors.len(), 2, "should reject invalid mappings only"); @@ -493,7 +541,13 @@ mod tests { mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should keep accepted count before failure"); assert_eq!( @@ -521,7 +575,7 @@ mod tests { .expect("should build test request"); let response = - handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); + handle_batch_sync(&kv, ®istry, &limiter, None, req).expect("should return response"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, @@ -581,7 +635,13 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-2", 101)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 0, "should not accept ineligible mappings"); assert_eq!(errors.len(), 2, "should report both errors"); @@ -591,13 +651,104 @@ mod tests { assert_eq!(errors[1].reason, REASON_INELIGIBLE); } + #[test] + fn process_mappings_accepts_an_identifier_from_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one still + // has to accept the identifiers that provider minted. Before the + // dispatch these were rejected outright by the HMAC grammar, so a + // partner could never sync a mapping against them. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let mappings = vec![mapping("t0op~Opaque_Value_MixedCase", "uid-1", 100)]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 1, "the active provider's identifier is accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_rejects_a_code_no_configured_provider_reads() { + // The other side of the dispatch: a code belonging to a provider this + // deployment neither runs nor reads is not an identifier here, whatever + // its shape. + let writer = MockWriter::new(vec![]); + let hmac_shaped = format!("t0zz~{}.ABC123", "a".repeat(64)); + let mappings = vec![ + mapping("t0zz~Opaque_Value", "uid-1", 100), + mapping(&hmac_shaped, "uid-2", 100), + ]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 0, "an unknown provider code is not accepted"); + assert_eq!(errors.len(), 2, "both mappings should be rejected"); + assert!( + errors + .iter() + .all(|error| error.reason == REASON_INVALID_EC_ID), + "should reject as an invalid EC ID, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_canonicalizes_through_the_owning_provider() { + // KV normalization is dispatched the same way as validation. The + // built-in provider lowercases its hash segment, so a partner echoing + // uppercase hex still writes the row minted at generation time, while + // the opaque provider's own normalization leaves its value untouched. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let uppercase = format!("hmac~{}.ABC123", "A".repeat(64)); + let provider = hmac_provider(); + let accepted_providers = AcceptedProviders::active(Some(&provider)); + + assert_eq!( + accepted_providers.canonical_kv_key(&uppercase), + Some(format!("hmac~{}.ABC123", "a".repeat(64))), + "the built-in provider should lowercase only its hash segment" + ); + assert_eq!( + AcceptedProviders::active(Some(&OpaqueProvider)) + .canonical_kv_key("t0op~Opaque_Value_MixedCase"), + Some("t0op~Opaque_Value_MixedCase".to_owned()), + "an opaque provider's identifier should be keyed verbatim" + ); + + let mappings = vec![mapping(&uppercase, "uid-1", 100)]; + let (accepted, errors) = + process_mappings(&writer, "partner", &mappings, &accepted_providers); + assert_eq!(accepted, 1, "uppercase hex should still be accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + #[test] fn process_mappings_counts_unchanged_as_accepted() { let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count unchanged mappings as accepted"); assert!( @@ -615,7 +766,13 @@ mod tests { mapping(&ec_id, "uid-old", 100), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!( accepted, 2, @@ -632,7 +789,13 @@ mod tests { let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 1)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should accept a coded HMAC identifier"); assert!( diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 99198c69b..88eb3ee67 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -190,12 +190,17 @@ pub fn is_valid_ec_hash(value: &str) -> bool { /// the random suffix allows mixed-case alphanumeric characters by /// construction. /// -/// A minted identifier carries the provider-code envelope, `hmac~` before -/// the bare value, and that is the form the partner-facing paths (pull sync, -/// batch sync, the admin lookup) receive, so both the enveloped and the -/// legacy bare form are accepted. An identifier under any other provider's -/// code is not an HMAC identifier and is rejected here: those paths accept -/// only the built-in provider's identifiers today. +/// A minted identifier carries the provider-code envelope, `hmac~` before the +/// bare value, so both the enveloped and the legacy bare form are accepted +/// here. An identifier under any other provider's code is not an HMAC +/// identifier and is rejected. +/// +/// This is the built-in provider's grammar, not the deployment's. The +/// partner-facing paths (pull sync, batch sync, the admin lookup) dispatch by +/// provider code through +/// [`AcceptedProviders`](super::provider::AcceptedProviders), which reaches +/// this only for an identifier the built-in provider owns, or as the fallback +/// for a stateless deployment that has selected no provider at all. #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { let bare = match split_provider_code(value) { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c530b0476..37e66acea 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -492,19 +492,27 @@ impl EcContext { self.ec_value.as_deref() } + /// The providers whose identifiers this request's paths accept. + /// + /// Today that is the selected provider alone (see + /// [`AcceptedProviders`](provider::AcceptedProviders) for the + /// `legacy_providers` seam). + #[must_use] + pub(crate) fn accepted_providers(&self) -> provider::AcceptedProviders<'_> { + provider::AcceptedProviders::active(self.selected_provider.as_deref()) + } + /// Returns whether `value` is a well-formed identifier for the selected /// provider. /// /// Lets core validate a cookie or active identifier (for example before /// withdrawing it) through the provider that issued it, rather than assuming - /// the built-in shape. Falls back to the built-in shape when no provider is - /// configured. + /// the built-in shape. The global cookie bounds are checked first, then the + /// provider-specific part is dispatched by the identifier's code. Falls back + /// to the built-in shape when no provider is configured. #[must_use] pub(crate) fn accepts_id(&self, value: &str) -> bool { - self.selected_provider.as_ref().map_or_else( - || is_valid_ec_id(value), - |provider| provider::provider_owns_id(provider.as_ref(), value), - ) + self.accepted_providers().accepts(value) } /// Returns whether the `ts-ec` cookie was present on the incoming request. diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index bc3f0a4a9..468cdcd17 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -31,6 +31,7 @@ use crate::evidence::RequestInfo; use crate::redacted::Redacted; use crate::settings::Ec; +use super::cookies::ec_id_has_only_allowed_chars; use super::generation; /// The Edge Cookie identity provider a deployment has selected. @@ -371,6 +372,93 @@ pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String } } +/// The providers whose identifiers a partner or diagnostic path accepts. +/// +/// Pull sync, batch sync, and the admin lookup each take an identifier from +/// outside the organic request path and have to decide whether Trusted Server +/// issued it. The answer is in two parts. The **global cookie bounds** (the +/// length cap and the cookie-safe alphabet, see `ec_id_has_only_allowed_chars`) +/// apply to every identifier whichever provider minted it. The rest is +/// **dispatched by the `{code}~` prefix** to the provider that owns that code, +/// which canonicalizes its own value part and decides whether the canonical +/// form is one of its own. A code no provider in the set owns is rejected, so a +/// second provider's identifiers can never be adopted or written under this +/// deployment's keys. +/// +/// The set holds the deployment's active provider. The design's +/// `legacy_providers` reader list, the providers that never mint but must still +/// recognize identifiers a previous provider issued, is not implemented on this +/// branch, so [`active`](Self::active) fills `readers` with the one active +/// provider. That is the seam: when the configured legacy readers land they are +/// built alongside the active provider and pushed into the same list, and +/// neither [`accepts`](Self::accepts) nor +/// [`canonical_kv_key`](Self::canonical_kv_key) changes. +pub struct AcceptedProviders<'a> { + readers: Vec<&'a dyn EdgeCookieProvider>, +} + +impl<'a> AcceptedProviders<'a> { + /// The set holding only the deployment's active provider. + /// + /// `None` means no provider is selected, so the deployment is stateless. + #[must_use] + pub fn active(provider: Option<&'a dyn EdgeCookieProvider>) -> Self { + Self { + readers: provider.into_iter().collect(), + } + } + + /// The provider in the set that owns `full`'s code. + /// + /// Dispatch is on the code alone, before any provider looks at a value, so + /// an identifier a partner echoed back in a different case still reaches + /// its own provider to be canonicalized rather than being rejected first. + /// A legacy bare identifier predates the envelope and belongs to the + /// built-in HMAC provider alone. + fn owner(&self, full: &str) -> Option<&'a dyn EdgeCookieProvider> { + let (code, _) = split_provider_code(full); + self.readers.iter().copied().find(|provider| match code { + Some(code) => provider.code().as_str() == code, + None => provider.id() == EcProviderSelection::HMAC_KEY, + }) + } + + /// Whether `full` is an identifier this deployment accepts. + #[must_use] + pub fn accepts(&self, full: &str) -> bool { + self.canonical_kv_key(full).is_some() + } + + /// The identity-graph key for `full`, or `None` when nothing in the set + /// accepts it. + /// + /// The owning provider supplies the canonical form of its own value part + /// and the code prefix is preserved verbatim, so two providers' rows can + /// never share a key. + #[must_use] + pub fn canonical_kv_key(&self, full: &str) -> Option { + if !ec_id_has_only_allowed_chars(full) { + return None; + } + match self.owner(full) { + Some(owner) => { + let key = provider_kv_key(owner, full); + provider_owns_id(owner, &key).then_some(key) + } + // No provider is selected, so there is no code to dispatch on and + // the built-in HMAC grammar is the fallback, the same fallback + // `EcContext::accepts_id` has always used for a stateless + // deployment. + None if self.readers.is_empty() => { + let key = generation::normalize_ec_id_for_kv(full); + generation::is_valid_ec_id(&key).then_some(key) + } + // A code that belongs to some other deployment's provider. + None => None, + } + } +} + /// A strategy for deriving an Edge Cookie identifier. /// /// Implementations are selected by configuration. A provider derives the @@ -741,6 +829,63 @@ mod tests { } } + #[test] + fn accepted_providers_splits_global_bounds_from_provider_dispatch() { + let hmac = HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())); + let hmac_value = format!("{}.ABC123", "a".repeat(64)); + let active = AcceptedProviders::active(Some(&hmac)); + + // The global bounds come first and apply whoever minted the value: a + // character outside the cookie-safe alphabet, or a value over the + // length cap, never reaches a provider. + assert!( + !active.accepts(&format!("hmac~{hmac_value} with spaces")), + "the cookie-safe alphabet is a global bound" + ); + assert!( + !active.accepts(&format!("hmac~{}", "a".repeat(300))), + "the length cap is a global bound" + ); + + // Then dispatch by code to the provider that owns it. + assert!( + active.accepts(&format!("hmac~{hmac_value}")), + "the active provider's own code is accepted" + ); + assert!( + active.accepts(&hmac_value), + "the legacy bare form belongs to the built-in provider" + ); + assert!( + !active.accepts(&format!("t0ac~{hmac_value}")), + "a code no configured provider reads is rejected even in the HMAC shape" + ); + + // A vendor provider's own identifiers are accepted when it is the + // active one, and the built-in bare form then belongs to nobody. + let vendor = AcceptedProviders::active(Some(&VendorProvider)); + assert!( + vendor.accepts(&format!("t0ac~{hmac_value}")), + "the vendor provider's code is accepted when it is active" + ); + assert!( + !vendor.accepts(&hmac_value), + "the legacy bare form is the built-in provider's alone" + ); + + // With no provider selected the deployment is stateless, so the + // built-in grammar is the fallback, as it has always been. + let stateless = AcceptedProviders::active(None); + assert!( + stateless.accepts(&hmac_value), + "a stateless deployment falls back to the built-in grammar" + ); + assert!( + !stateless.accepts("not-an-identifier"), + "the fallback is still the built-in grammar, not anything goes" + ); + } + #[test] fn the_selector_round_trips_through_serialization() { // The typed selector must not change the configuration surface. The diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 1caa498c0..e55baeb40 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -19,7 +19,7 @@ use crate::platform::{ }; use crate::settings::Settings; -use super::generation::{ec_hash, is_valid_ec_id}; +use super::generation::ec_hash; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; use super::rate_limiter::RateLimiter; @@ -62,9 +62,15 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option &'static str { + "opaque" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::ec::provider::ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn build_pull_sync_context_accepts_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one must + // still dispatch pull sync for the identifiers that provider minted. + // The built-in grammar rejected every non-`hmac` code, so these + // identifiers worked in the organic path and were silently skipped + // here. + const OPAQUE_ID: &str = "t0op~Opaque_Value_MixedCase"; + + let mut settings = crate::test_support::tests::create_test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from("opaque")); + let services = crate::platform::test_support::noop_services_with_ec_provider( + std::sync::Arc::new(OpaqueProvider), + ); + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header("cookie", format!("ts-ec={OPAQUE_ID}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let geo = crate::geo::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + + let ec_context = + EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec_context.ec_value(), + Some(OPAQUE_ID), + "the opaque identifier should read back before pull sync sees it" + ); + + let context = build_pull_sync_context(&ec_context) + .expect("should dispatch pull sync for the active provider's identifier"); + assert_eq!( + context.ec_id(), + OPAQUE_ID, + "should carry the identifier through unchanged" + ); + } + #[test] fn build_pull_sync_context_rejects_invalid_ec_id() { let consent = ConsentContext { From 3b74d745e937b45657d3134e7d7bb317a458b7f4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:44:59 +0100 Subject: [PATCH 015/133] Let each provider decide whether it needs the client IP `EcContext::generate_if_needed` failed the request whenever the host could not determine a client IP, before the selected provider was asked anything. `RequestInfo::client_ip` already defines the empty string as the unavailable state and providers are meant to read only the evidence they need, so the generic check blocked every header-, cookie-, query- and client-derived provider that can work without an IP. The requirement moves into `HmacProvider`, whose only input is the client IP. With none it fails rather than hashing the empty string into an identifier every visitor on that host would share. That failure propagates out of `generate_if_needed` exactly as the old check did, so a provider that genuinely needs the IP and cannot get it still fails the request rather than quietly minting nothing, matching this branch's decision to stop rather than serve without identity. Providers that read other evidence now receive the documented empty value and run. `HmacProvider` is the only provider on this branch that reads the client IP; the injected vendor seam leaves the decision to each vendor crate. Tests cover both answers: a provider deriving identity from the request query and cookies mints on a host with no client IP, and the built-in HMAC provider refuses on the same host with no identifier committed. A `noop_services_with_ec_provider_without_client_ip` test helper models that host. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:376 (P2) --- crates/trusted-server-core/src/ec/mod.rs | 97 ++++++++++++++++--- crates/trusted-server-core/src/ec/provider.rs | 17 +++- .../src/platform/test_support.rs | 27 +++++- 3 files changed, 124 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 37e66acea..28c3e3ae3 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -316,8 +316,9 @@ impl EcContext { /// /// # Errors /// - /// Returns an error if the client IP is unavailable and generation is - /// needed, or if HMAC generation fails. + /// Returns an error if the selected provider fails to derive an identifier, + /// which includes a provider that needs the client IP being run on a host + /// that cannot supply one. pub fn generate_if_needed( &mut self, settings: &Settings, @@ -343,16 +344,14 @@ impl EcContext { return Ok(()); } - // EC generation needs the client IP; checked after the cheap skip - // guards so a stateless deployment on a host with no client IP does not - // log spurious errors. The provider reads it borrowed at generate time - // (see [`generate_with_provider`]), so nothing is cloned here. - if self.client_ip.is_none() { - return Err(Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_owned(), - })); - } - + // Whether the client IP is needed is the selected provider's decision, + // not core's. A provider that derives identity from headers, cookies, + // query parameters, or the client reads no IP and must still run on a + // host that cannot supply one. The IP is passed as the documented + // unavailable value, the empty string (see + // [`RequestInfo::client_ip`](crate::evidence::RequestInfo::client_ip)), + // and a provider that needs it refuses there, which fails the request + // rather than serving without identity. self.generate_with_provider(ec_provider.as_ref(), settings, kv) } @@ -370,8 +369,9 @@ impl EcContext { /// /// # Errors /// - /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is - /// unavailable, the provider fails to derive an identifier, the provider + /// Returns [`TrustedServerError::EdgeCookie`] when the provider fails to + /// derive an identifier (which for [`HmacProvider`] includes an + /// unavailable client IP), the provider /// asks for a response header inside core's reserved surface (see /// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)), /// or persisting a generated identifier to the KV identity graph fails. @@ -1065,6 +1065,75 @@ mod tests { ); } + #[test] + fn a_provider_that_reads_no_client_ip_mints_when_the_host_has_none() { + use crate::platform::test_support::noop_services_with_ec_provider_without_client_ip; + + // The requirement for a client IP belongs to the provider that uses + // one, not to core. A provider deriving identity from the request + // query and cookies runs on a host that cannot determine a client IP, + // and receives the documented unavailable value, the empty string. + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + ec.generate_if_needed(&settings, None) + .expect("a provider that reads no client IP should still mint"); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier should be committed with no client IP available" + ); + } + + #[test] + fn the_hmac_provider_refuses_when_the_host_has_no_client_ip() { + // The other half: the built-in provider's only input is the client IP, + // so with none it fails rather than hashing the empty string into an + // identifier every visitor on that host would share. Identity cannot be + // established, so the request fails rather than being served without. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("the HMAC provider should refuse without a client IP"); + assert!( + err.to_string().contains("client IP"), + "the error should name the missing client IP, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed when the provider refuses" + ); + } + /// A provider that mints an identifier outside the cookie-safe alphabet, /// to prove core rejects it at mint rather than rewriting it. #[derive(Debug)] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 468cdcd17..80dfb3d24 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -531,6 +531,14 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Derives the identifier from the client IP (read from the [`RequestInfo`] /// passed at call time) and the configured passphrase via /// [`generation::generate_ec_id`]. +/// +/// The client IP is this provider's only input, so it is this provider that +/// requires one. On a host that cannot supply one, [`RequestInfo::client_ip`] +/// is the empty string and [`generate`](Self::generate) fails rather than +/// hashing the empty string into an identifier every visitor on that host +/// would share. The failure reaches the caller, so the request fails rather +/// than being served without identity. A provider that reads other evidence +/// makes its own decision and is unaffected. #[derive(Debug, Clone)] pub struct HmacProvider { passphrase: Redacted, @@ -558,7 +566,14 @@ impl EdgeCookieProvider for HmacProvider { request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, ) -> Result> { - let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + let client_ip = request_info.client_ip(); + if client_ip.is_empty() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` requires the client IP, and this host could not supply one" + .to_owned(), + })); + } + let id = generation::generate_ec_id(self.passphrase.expose(), client_ip)?; Ok(GeneratedEdgeCookie { id: Some(id), response_headers: Vec::new(), diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index cecb902fa..cdfd3b96d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -693,6 +693,30 @@ pub(crate) fn noop_services() -> RuntimeServices { /// reaches core through. pub(crate) fn noop_services_with_ec_provider( ec_provider: Arc, +) -> RuntimeServices { + // A fixed client IP, so a provider that reads one (the built-in HMAC + // provider does) can run. + noop_services_with_ec_provider_and_ip( + ec_provider, + Some("203.0.113.10".parse().expect("should parse test client IP")), + ) +} + +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider and no +/// client IP, modeling a host that cannot determine one. +/// +/// Whether that matters is the provider's decision, so this exists to test both +/// answers: a provider reading other evidence still mints, and one that needs +/// the IP refuses. +pub(crate) fn noop_services_with_ec_provider_without_client_ip( + ec_provider: Arc, +) -> RuntimeServices { + noop_services_with_ec_provider_and_ip(ec_provider, None) +} + +fn noop_services_with_ec_provider_and_ip( + ec_provider: Arc, + client_ip: Option, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) @@ -701,9 +725,8 @@ pub(crate) fn noop_services_with_ec_provider( .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) .geo(Arc::new(NoopGeo)) - // A fixed client IP so the generate path (which requires one) can run. .client_info(ClientInfo { - client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + client_ip, ..ClientInfo::default() }) .ec_provider(ec_provider) From 29a3a7f23d600d5a56f08d5104fe20f1ecc8426f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:52:04 +0100 Subject: [PATCH 016/133] State a real retirement condition for the legacy bare-identifier reader The comment on `provider_owns_id` promised bare-HMAC compatibility for one release cycle, which the code cannot honor. A returning visitor's bare cookie is never rewritten into the coded form, so the promise was shorter than the cookie's own life. The comment now states the condition the quantities actually support, read off the code rather than estimated. Neither the cookie nor its identity-graph row is refreshed on an ordinary page view (see `ec_finalize_response`), so each has one fixed lifetime from the moment it was written: `COOKIE_MAX_AGE` in `ec/cookies.rs` and `ENTRY_TTL` in `ec/kv.rs`, both one year and neither operator-configurable. The earliest safe retirement is one year after the last release that could still mint a bare identifier has stopped running anywhere, plus the deployment's own rollout skew. The comment also says plainly that the second half of the condition cannot be checked: nothing counts or logs a bare-form read-back, so there is no observed legacy-reader traffic to look at and elapsed time alone proves nothing. No metric is named that is not emitted. The reader stays, at the cost of one string comparison per read-back, and the one-release wording is gone from the comment and from the provider code registry. A new test in `ec/cookies.rs` pins `COOKIE_MAX_AGE` to one year, next to the existing `ENTRY_TTL` assertion in `ec/kv.rs`, so the two figures the retirement condition is written in terms of cannot drift unnoticed. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:145 (non-blocking) --- crates/trusted-server-core/src/ec/cookies.rs | 13 ++++++++++ crates/trusted-server-core/src/ec/provider.rs | 26 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 1b3da4785..d25e63641 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -130,6 +130,19 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) mod tests { use super::*; + #[test] + fn the_ec_cookie_lifetime_is_one_year() { + // The legacy bare-identifier reader's retirement condition (see + // `provider_owns_id`) is written in terms of this lifetime and the + // identity-graph `ENTRY_TTL`, which `kv::tests::constants_have_expected_values` + // pins to the same figure. Changing either moves the earliest safe + // retirement, so neither may drift unnoticed. + assert_eq!( + COOKIE_MAX_AGE, 31_536_000, + "the EC cookie should live one year" + ); + } + #[test] fn identifier_bounds_reject_oversize_and_accept_tilde() { assert!( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 80dfb3d24..d833eaea8 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -338,8 +338,30 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { /// carries, with the value part accepted by that provider's /// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier /// (no code prefix) belongs only to the built-in HMAC provider, which -/// dual-reads its pre-envelope form for one release cycle so deployed cookies -/// keep working across the migration. +/// dual-reads its pre-envelope form so deployed cookies keep working across +/// the migration. +/// +/// # Retiring the legacy bare reader +/// +/// The reader stays until a bare identifier can no longer arrive. A returning +/// visitor's bare cookie is never rewritten into the coded form, and neither +/// the cookie nor its identity-graph row is refreshed on an ordinary page view +/// (see `ec_finalize_response` in [`finalize`](super::finalize)), so each has +/// one fixed lifetime running from the moment it was written: `COOKIE_MAX_AGE` +/// in [`cookies`](super::cookies) and `ENTRY_TTL` in [`kv`](super::kv), both +/// one year, neither operator-configurable. The earliest safe retirement is +/// therefore one year (the longer of the two, and today they are equal) after +/// the last release that could still mint a bare identifier has stopped +/// running anywhere, plus however long a deployment's own rollout takes to +/// reach every point of presence. +/// +/// The other half of that condition, evidence that bare identifiers really +/// have stopped arriving, cannot be checked today. Nothing counts or logs a +/// bare-form read-back, so there is no observed legacy-reader traffic to look +/// at, and the elapsed time alone cannot tell anyone whether a deployment +/// somewhere is still serving them. Scheduling the removal needs that signal +/// to exist first. Until it does the reader stays, and keeping it costs one +/// string comparison per read-back. #[must_use] pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { match split_provider_code(full) { From cb62786e8cfd46a812b7313dcc2143593f88ce90 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:14:39 +0100 Subject: [PATCH 017/133] Key identity-graph reads and write-backs by the canonical form The lifecycle contract routes every identity-graph row through the owning provider's canonical form, and generation already did: it keys the row it creates with `provider_kv_key`. Three read and write-back paths did not. `handle_identify` read with the raw cookie value, the withdrawal tombstones in `ec_finalize_response` were written under the raw value, and EID ingestion keyed its upsert by the raw value too. Raw and canonical are the same string for the built-in HMAC provider, so nothing misbehaved. For the first provider whose canonical form differs from the cookie value, which is exactly the case the mint test on this branch already pins, identify missed the row generation had written, an ingested EID was dropped because the upsert found no row under the raw value, and a withdrawal tombstone landed on a key no live row used, so the revocation never took effect. The key is now derived in one place, `EcContext::kv_key_for`, reached by `ec_kv_key` for the active identifier and `cookie_ec_kv_key` for the `ts-ec` cookie the request carried. Both go through `AcceptedProviders`, so the owning provider is picked by the identifier's `{code}~` prefix and supplies the canonical form of its own value part. Identify, the tombstones, and both EID ingestion call sites use them. `AcceptedProviders` came from the partner-path dispatch commit and fixed none of these three; it changed pull sync, batch sync, and the admin lookup only. What it did give this fix is `canonical_kv_key`, the code-dispatched derivation these paths now share, and it also subsumes the shape filter `withdrawal_ec_ids` applied by hand: a key exists exactly when some provider this deployment reads owns the identifier, so `withdrawal_kv_keys` filters by deriving. The mint test now asserts `ec_kv_key` returns the key generation actually wrote, so the read side and the write side cannot drift apart. Tests: identify finds the row generation keyed by the canonical form and still echoes the cookie value to the partner; a withdrawal tombstones the canonical row and writes nothing under the raw cookie value; an ingested EID joins the canonical row. Each was run against the unfixed code first and each failed there. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/identify.rs:89 (wrench) --- crates/trusted-server-core/src/ec/finalize.rs | 206 +++++++++++++++--- crates/trusted-server-core/src/ec/identify.rs | 137 +++++++++--- crates/trusted-server-core/src/ec/mod.rs | 64 +++++- 3 files changed, 345 insertions(+), 62 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index b27b700a3..908cb5959 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -78,16 +78,16 @@ pub fn ec_finalize_response( expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. - let ids_to_withdraw = withdrawal_ec_ids(ec_context); + let keys_to_withdraw = withdrawal_kv_keys(ec_context); // The identity-graph tombstone is the authoritative withdrawal marker // for subsequent EC behavior. if let Some(graph) = kv { - apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| { - if let Err(err) = graph.write_withdrawal_tombstone(ec_id) { + apply_withdrawal_tombstones(&keys_to_withdraw, |kv_key| { + if let Err(err) = graph.write_withdrawal_tombstone(kv_key) { log::error!( "Failed to write withdrawal tombstone for EC ID '{}': {err:?}", - log_id(ec_id), + log_id(kv_key), ); } }); @@ -99,8 +99,12 @@ pub fn ec_finalize_response( // Returning user: EC is permitted and came from the request. if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { - if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + // Key EID ingestion by the provider's canonical form of the identifier, + // the key the identity-graph row is stored under, so an ingested EID + // lands on the live row rather than creating a second one keyed by the + // value the browser carries. + if let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) { + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); } // Ordinary returning-user page views no longer refresh the browser @@ -112,12 +116,14 @@ pub fn ec_finalize_response( // there is no KV graph: that would mint a browser cookie with no backing // identity-graph row, producing a phantom ID on later requests. if ec_context.ec_generated() { - let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else { - log::info!("Skipping generated EC response write because KV graph is unavailable"); + let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) else { + log::info!( + "Skipping generated EC response write because the KV graph or the identity-graph key is unavailable" + ); return; }; - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); set_ec_cookie_on_response(settings, ec_context, response); } } @@ -167,30 +173,35 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response HashSet { - let mut hashes = HashSet::new(); +/// The identity-graph keys a withdrawal must tombstone. +/// +/// Both the `ts-ec` cookie the request carried and the active identifier are +/// turned into keys by the provider that owns them, so the tombstone lands on +/// the row the live identifier is stored under rather than on the raw cookie +/// value. An identifier no provider this deployment reads owns produces no key +/// and is dropped, which is the same filtering the previous shape check did. +/// The two collapse to one key when they are the same identity written two +/// ways. +fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { + let mut keys = HashSet::new(); - if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && ec_context.accepts_id(cookie_ec_id) - { - hashes.insert(cookie_ec_id.to_owned()); + if let Some(cookie_kv_key) = ec_context.cookie_ec_kv_key() { + keys.insert(cookie_kv_key); } - if let Some(active_ec_id) = ec_context.ec_value() - && ec_context.accepts_id(active_ec_id) - { - hashes.insert(active_ec_id.to_owned()); + if let Some(active_kv_key) = ec_context.ec_kv_key() { + keys.insert(active_kv_key); } - hashes + keys } -fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F) +fn apply_withdrawal_tombstones(kv_keys: &HashSet, mut write_tombstone: F) where F: FnMut(&str), { - for ec_id in ec_ids { - write_tombstone(ec_id); + for kv_key in kv_keys { + write_tombstone(kv_key); } } @@ -270,6 +281,50 @@ mod tests { ) } + /// The identifier [`CanonicalizingProvider`] mints, as the browser carries + /// it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the mint path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn canonicalizing_context( + ec_was_present: bool, + ec_generated: bool, + consent: ConsentContext, + ec_allowed: bool, + ) -> EcContext { + make_context_with_consent( + Some(CANONICAL_COOKIE_VALUE), + Some(CANONICAL_COOKIE_VALUE), + ec_was_present, + ec_generated, + consent, + ec_allowed, + ) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )) + } + + fn graph_with_live_canonical_row() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("finalize-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + graph + } + fn sample_ec_id(suffix: &str) -> String { format!("{}.{suffix}", "a".repeat(64)) } @@ -292,7 +347,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { + fn withdrawal_kv_keys_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); let ec_context = make_context( None, @@ -303,7 +358,7 @@ mod tests { false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should include exactly one EC ID"); assert!( @@ -313,7 +368,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_deduplicates_matching_cookie_and_active_ec() { + fn withdrawal_kv_keys_deduplicates_matching_cookie_and_active_ec() { let ec_id = sample_ec_id("same01"); let ec_context = make_context( Some(&ec_id), @@ -324,14 +379,14 @@ mod tests { false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should deduplicate identical EC IDs"); assert!(ids.contains(&ec_id), "should retain the shared EC ID"); } #[test] - fn withdrawal_ec_ids_includes_both_cookie_and_active_when_different() { + fn withdrawal_kv_keys_includes_both_cookie_and_active_when_different() { let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); let ec_context = make_context( @@ -343,7 +398,7 @@ mod tests { false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 2, "should include both distinct EC IDs"); assert!(ids.contains(&active_ec), "should include active EC ID"); @@ -351,7 +406,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_filters_invalid_values() { + fn withdrawal_kv_keys_filters_invalid_values() { let valid_ec = sample_ec_id("valid1"); let ec_context = make_context( Some(&valid_ec), @@ -362,7 +417,7 @@ mod tests { false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should ignore malformed EC values"); assert!(ids.contains(&valid_ec), "should keep the valid EC ID"); @@ -718,4 +773,93 @@ mod tests { "a closed consent gate must not write a ts-ec cookie" ); } + + #[test] + fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { + // The tombstone is the authoritative revocation marker, so it has to + // land on the key the live row uses. Written under the raw cookie + // value it creates a second row nothing reads, and the revocation + // never takes effect for a provider whose canonical form differs. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, false); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let (live_row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert!( + !live_row.consent.ok, + "withdrawal should tombstone the row the live identifier is keyed by" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "withdrawal should not write a tombstone under the raw cookie value" + ); + } + + #[test] + fn eid_ingestion_keys_by_the_providers_canonical_form() { + // An ingested EID must join the row the identifier already has. Keyed + // by the raw cookie value the upsert finds no row and the partner ID + // is dropped. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, true); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + ®istry, + None, + Some("shared-cookie-id"), + &mut response, + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert_eq!( + row.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some("shared-cookie-id"), + "the ingested EID should land on the row keyed by the canonical form" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "EID ingestion should not create a row under the raw cookie value" + ); + } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index eeadaa290..b9b423956 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -86,40 +86,53 @@ pub fn handle_identify( let mut uid: Option = None; let mut cluster_size: Option = None; - match kv.get(ec_id) { - Ok(Some((entry, generation))) => { - if !entry.consent.ok { - // Tombstone entries preserve the withdrawal signal for 24 hours. - // Do not extract IDs or evaluate cluster size because that would - // write back with the live-entry TTL. - log::trace!("Identify found tombstone for '{}'", log_id(ec_id)); - } else { - // Extract only this partner's UID. - if let Some(partner_uid) = entry.ids.get(&partner.source_domain) - && !partner_uid.uid.is_empty() - { - uid = Some(partner_uid.uid.clone()); - } - - // Evaluate cluster size lazily for identify responses. Existing - // stored cluster_size values are reused without a prefix-list call. - match kv.evaluate_cluster(ec_id, &entry, generation) { - Ok(size) => { - cluster_size = size; + // Read the identity-graph row under the provider's canonical form of the + // identifier, the same key generation wrote, rather than under the value the + // browser carries. The two are the same string for the built-in HMAC + // provider and differ for any provider whose canonical form is not the + // cookie value. `None` means no provider this deployment reads owns the + // identifier, so there is no row to look for and the response is not + // degraded. + if let Some(kv_key) = ec_context.ec_kv_key() { + match kv.get(&kv_key) { + Ok(Some((entry, generation))) => { + if !entry.consent.ok { + // Tombstone entries preserve the withdrawal signal for 24 + // hours. Do not extract IDs or evaluate cluster size because + // that would write back with the live-entry TTL. + log::trace!("Identify found tombstone for '{}'", log_id(&kv_key)); + } else { + // Extract only this partner's UID. + if let Some(partner_uid) = entry.ids.get(&partner.source_domain) + && !partner_uid.uid.is_empty() + { + uid = Some(partner_uid.uid.clone()); } - Err(err) => { - log::warn!("Cluster evaluation failed for '{}': {err:?}", log_id(ec_id)); + + // Evaluate cluster size lazily for identify responses. + // Existing stored cluster_size values are reused without a + // prefix-list call. + match kv.evaluate_cluster(&kv_key, &entry, generation) { + Ok(size) => { + cluster_size = size; + } + Err(err) => { + log::warn!( + "Cluster evaluation failed for '{}': {err:?}", + log_id(&kv_key) + ); + } } } } - } - Ok(None) => {} - Err(err) => { - log::warn!( - "Identify KV read failed for EC ID '{}': {err:?}", - log_id(ec_id) - ); - degraded = true; + Ok(None) => {} + Err(err) => { + log::warn!( + "Identify KV read failed for EC ID '{}': {err:?}", + log_id(&kv_key) + ); + degraded = true; + } } } @@ -350,6 +363,17 @@ mod tests { ); } + /// The identifier [`CanonicalizingProvider`] mints, as the browser carries + /// it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the mint path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests, which asserts both the key it writes and the key + /// [`EcContext::ec_kv_key`] derives. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { source: ConsentSource::Cookie, @@ -753,4 +777,57 @@ mod tests { "should vary on identity request inputs for preflight" ); } + + #[test] + fn handle_identify_reads_the_row_under_the_providers_canonical_key() { + // A provider whose canonical form is not the cookie value keys its row + // under the canonical form at generation. Identify has to look there, + // or every such deployment reads a miss for every request and reports + // no partner UID at all. + let settings = create_test_settings(); + let kv = KvIdentityGraph::in_memory("identify-canonical-store"); + kv.create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let ec_context = make_ec_context(true, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )); + + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .expect("should build identify response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let body = serde_json::from_slice::( + &response.into_body().into_bytes().unwrap_or_default(), + ) + .expect("should decode identify response JSON"); + assert_eq!( + body["ec"], CANONICAL_COOKIE_VALUE, + "should echo the identifier the browser carries, not the graph key" + ); + assert_eq!( + body["uid"], "partner-uid-123", + "should find the row generation keyed by the provider's canonical form" + ); + assert_eq!( + body["degraded"], + serde_json::Value::Bool(false), + "a hit under the canonical key is not a degraded read" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 28c3e3ae3..70093d4f6 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -515,6 +515,41 @@ impl EcContext { self.accepted_providers().accepts(value) } + /// The identity-graph key for `value` under the providers this deployment + /// reads. + /// + /// The one place core turns an identifier into a row key. Every read and + /// write of an identity-graph row goes through this, so a provider whose + /// canonical form differs from the cookie value still finds the row it + /// minted. The owning provider is picked by the identifier's `{code}~` + /// prefix and supplies the canonical form of its own value part, matching + /// what [`generate_if_needed`](Self::generate_if_needed) wrote at mint. + /// + /// `None` when no provider this deployment reads owns `value`, in which + /// case there is no row to read or write. + #[must_use] + pub(crate) fn kv_key_for(&self, value: &str) -> Option { + self.accepted_providers().canonical_kv_key(value) + } + + /// The identity-graph key for this request's active identifier. + #[must_use] + pub(crate) fn ec_kv_key(&self) -> Option { + self.ec_value().and_then(|value| self.kv_key_for(value)) + } + + /// The identity-graph key for the `ts-ec` cookie the request carried. + /// + /// Withdrawal tombstones the cookie's row as well as the active one, + /// because a stateless deployment and a cookie the active provider no + /// longer mints both leave [`ec_kv_key`](Self::ec_kv_key) empty while a + /// live row still exists. + #[must_use] + pub(crate) fn cookie_ec_kv_key(&self) -> Option { + self.existing_cookie_ec_id() + .and_then(|value| self.kv_key_for(value)) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -613,6 +648,22 @@ impl EcContext { self.ec_value.as_deref().map(generation::ec_hash) } + /// Attaches a selected provider to a test-only [`EcContext`]. + /// + /// The production constructor builds the provider from settings and + /// injected services. A test that only needs the provider's identifier + /// semantics (which identifiers it owns, and their canonical key form) + /// takes this shortcut instead. + #[cfg(test)] + #[must_use] + pub fn with_provider_for_test( + mut self, + provider: Arc, + ) -> Self { + self.selected_provider = Some(provider); + self + } + /// Creates a test-only `EcContext` whose creation gate is derived from the /// consent context, matching the production construction path. /// @@ -1344,8 +1395,11 @@ mod tests { /// A provider whose identifier normalizes to a distinct canonical form, to /// prove the identity graph is keyed by the canonical form. + /// + /// Shared with the identify and finalization tests, which need a provider + /// whose canonical key is not the value the browser carries. #[derive(Debug)] - struct CanonicalizingProvider; + pub(crate) struct CanonicalizingProvider; impl EdgeCookieProvider for CanonicalizingProvider { fn id(&self) -> &'static str { @@ -1403,6 +1457,14 @@ mod tests { .is_some(), "the graph row should be keyed by the code plus the canonical form" ); + // Pin the read-side derivation to the key generation actually wrote. + // Identify, the withdrawal tombstones, and EID ingestion all read the + // row through `ec_kv_key`, so the two must never drift apart. + assert_eq!( + ec.ec_kv_key().as_deref(), + Some("t0ca~mixed.caseid"), + "the read-side key should be the key generation wrote" + ); } #[test] From fe23dcc55918552e198e7eb7a2339577ba7b0820 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:30:31 +0100 Subject: [PATCH 018/133] Egress only an Edge Cookie identifier the provider recognizes Section 3's Recognize row says a value the selected provider does not recognize "is never used or egressed". Three paths egressed one anyway. `append_ec_id` put the raw `ts-ec` cookie or `x-ts-ec` header on the outbound origin URL, `handle_first_party_click` put it on the click target's redirect URL, and the testlight integration put it in the proxied body as `user.id`. All three read through `edge_cookie::get_ec_id`, which checks the cookie-safe alphabet and the length cap and nothing else, so a value carrying another deployment's provider code (`zz00~...`), and any cookie at all in a deployment with no provider selected, was handed on. The code changes rather than the claim. `edge_cookie::recognized_ec_id` reads the value and then asks the selected provider whether it owns it, through `provider_owns_id`, which is the same test `EcContext` applies when it reads the cookie back, so the egress paths and the EC lifecycle agree on what this deployment issued. All three call sites use it. Behavior change: a deployment with no Edge Cookie provider selected now forwards no `ts-ec` value at all, on any of the three paths. It previously forwarded whatever the browser sent. An operator running stateless and relying on the raw cookie reaching the origin, the click target, or the testlight upstream will see that value stop arriving, and testlight, which requires an identifier, will fail the request rather than proxy it. The fix for such a deployment is to select a provider, which is what makes the value this deployment's to hand on. The testlight call site is in scope on the evidence rather than by assumption: its value is written into the request body as `user.id` by `rewrite_request_body` and that body is POSTed to the operator-configured endpoint, so the identifier leaves the edge even though the integration sets `forward_ec_id = false` (which suppresses only the query-parameter copy on the same request). The spec's Recognize row now names the three egress paths in the column that says where core applies recognition, and records that a stateless deployment recognizes nothing and so egresses nothing. The claim is left as strong as it was. Tests: each of the three paths, with a foreign-coded value and with a stateless deployment, plus a positive control on each that the deployment's own identifier still gets through. The testlight cases assert no upstream call is made at all. `click_appends_ec_id_when_present` used `ec-123`, which no provider issues, and now uses an identifier the built-in HMAC provider owns. Every new test was run against the unfixed code and failed there. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/proxy.rs:1263 (question) --- crates/trusted-server-core/src/edge_cookie.rs | 55 ++++- .../src/integrations/testlight.rs | 99 ++++++++- crates/trusted-server-core/src/proxy.rs | 198 ++++++++++++++++-- 3 files changed, 331 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index e77f1a82c..f742f8997 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -13,13 +13,12 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] use crate::ec::generation::normalize_ip; #[cfg(test)] -use crate::ec::provider::{IdentityInput, build_provider}; +use crate::ec::provider::IdentityInput; +use crate::ec::provider::{build_provider, provider_owns_id}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; -#[cfg(test)] use crate::platform::RuntimeServices; -#[cfg(test)] use crate::settings::Settings; /// Generates a fresh EC ID using the configured Edge Cookie provider. @@ -119,6 +118,56 @@ pub fn get_ec_id(req: &Request) -> Result, Report, +) -> Result, Report> { + let Some(ec_id) = get_ec_id(req)? else { + return Ok(None); + }; + + let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + log::debug!( + "No Edge Cookie provider configured; withholding the request's EC ID from egress" + ); + return Ok(None); + }; + + if provider_owns_id(provider.as_ref(), &ec_id) { + return Ok(Some(ec_id)); + } + + log::debug!( + "Withholding an EC ID provider `{}` does not recognize from egress", + provider.id(), + ); + Ok(None) +} + /// Gets or creates an EC ID from the request. /// /// Attempts to retrieve an existing EC ID from: diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 80b2c4dfa..f6f54ee4a 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext, @@ -184,13 +184,16 @@ impl IntegrationProxy for TestlightIntegration { .await?; let req = http::Request::from_parts(parts, EdgeBody::empty()); - // Read EC ID from the ts-ec cookie forwarded by the client. - // The registry strips x-ts-ec before dispatching, so only the cookie is available here. - let ec_id = get_ec_id(&req) + // Read the EC ID from the ts-ec cookie forwarded by the client. The + // registry strips x-ts-ec before dispatching, so only the cookie is + // available here. The value goes into the proxied body as `user.id` and + // leaves the edge, so only one the selected provider recognizes is + // accepted, and a stateless deployment supplies none. + let ec_id = recognized_ec_id(settings, services, &req) .change_context(Self::error("Failed to read EC ID"))? .ok_or_else(|| { Report::new(Self::error( - "EC ID not found in ts-ec cookie — the client must carry a valid EC cookie", + "No EC ID this deployment's Edge Cookie provider recognizes was found \n in the ts-ec cookie", )) })?; @@ -467,4 +470,90 @@ mod tests { ); }); } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn testlight_auction_request(ec_id: &str) -> http::Request { + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/testlight/auction") + .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) + .expect("should build request"); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC.clone(), + http::HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn testlight_integration() -> Arc { + TestlightIntegration::new(TestlightConfig { + enabled: true, + endpoint: "https://example.com/openrtb".to_string(), + timeout_ms: 1000, + shim_src: tsjs::tsjs_unified_script_src(), + rewrite_scripts: true, + }) + } + + #[test] + fn handle_refuses_to_egress_an_ec_id_the_provider_does_not_recognize() { + futures::executor::block_on(async { + // The identifier ends up in the proxied body as `user.id` and leaves + // the edge, so a value this deployment did not issue must stop here + // rather than be handed to the upstream endpoint. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + + testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(FOREIGN_CODED_EC_ID), + ) + .await + .expect_err("a foreign provider code should not be proxied upstream"); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made with an unrecognized identifier" + ); + }); + } + + #[test] + fn handle_refuses_to_egress_any_ec_id_in_a_stateless_deployment() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + // The value is one the built-in provider would recognize, so only + // the absence of a selected provider can withhold it. + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + + testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(VALID_SYNTHETIC_ID), + ) + .await + .expect_err("a deployment that mints no identifier should proxy none"); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made without a recognized identifier" + ); + }); + } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 59017cf97..64ad90f35 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -23,7 +23,7 @@ use crate::constants::{ HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, }; use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, @@ -788,7 +788,7 @@ pub async fn proxy_request( })?; if forward_ec_id { - append_ec_id(&req, &mut target_url_parsed); + append_ec_id(settings, services, &req, &mut target_url_parsed); } proxy_with_redirects( @@ -1260,8 +1260,19 @@ fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { url.set_query(Some(&serializer.finish())); } -fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { - let ec_id_param = match get_ec_id(req) { +/// Forwards the request's Edge Cookie identifier to the outbound target URL. +/// +/// Only an identifier the selected provider recognizes is forwarded. A value +/// carrying another deployment's provider code, and any value at all in a +/// stateless deployment, is withheld, so nothing this deployment did not issue +/// reaches the origin. +fn append_ec_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + target_url_parsed: &mut url::Url, +) { + let ec_id_param = match recognized_ec_id(settings, services, req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -1597,7 +1608,7 @@ pub async fn handle_first_party_proxy( /// Returns an error if the signed target cannot be reconstructed or validation fails. pub async fn handle_first_party_click( settings: &Settings, - _services: &RuntimeServices, + services: &RuntimeServices, req: Request, ) -> Result, Report> { let SignedTarget { @@ -1606,7 +1617,10 @@ pub async fn handle_first_party_click( had_params, } = reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; - let ec_id = match get_ec_id(&req) { + // The redirect target is a third party's URL, so only an identifier the + // selected provider recognizes is added to it. A stateless deployment adds + // nothing. + let ec_id = match recognized_ec_id(settings, services, &req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -2220,17 +2234,18 @@ mod tests { use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, - SUPPORTED_ENCODINGS, asset_origin_host_header, asset_path_skips_image_optimizer, - build_asset_proxy_target_url, clear_s3_credentials_cache_for_tests, - handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - is_host_permitted, proxy_request, rebuild_response_with_body, + SUPPORTED_ENCODINGS, append_ec_id, asset_origin_host_header, + asset_path_skips_image_optimizer, build_asset_proxy_target_url, + clear_s3_credentials_cache_for_tests, handle_asset_proxy_request, handle_first_party_click, + handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, + is_host_allowed, is_host_permitted, proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, stream_asset_body, }; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; + use crate::platform::RuntimeServices; use crate::platform::test_support::{ HashMapSecretStore, StubHttpClient, build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, @@ -2249,6 +2264,7 @@ mod tests { use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; use error_stack::Report; + use http::Request; use http::{HeaderValue, Method, Request as HttpRequest, Response, StatusCode, header}; #[test] @@ -2962,7 +2978,8 @@ mod tests { ); req.headers_mut().insert( crate::constants::HEADER_X_TS_EC, - HeaderValue::from_static("ec-123"), + HeaderValue::from_str(&recognized_hmac_ec_id()) + .expect("should build EC header value"), ); let resp = handle_first_party_click(&settings, &noop_services(), req) @@ -2980,11 +2997,166 @@ mod tests { .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); assert_eq!(pairs.remove("foo").as_deref(), Some("1")); - assert_eq!(pairs.remove("ts-ec").as_deref(), Some("ec-123")); + assert_eq!( + pairs.remove("ts-ec").as_deref(), + Some(recognized_hmac_ec_id().as_str()) + ); assert!(pairs.is_empty()); }); } + /// An identifier the built-in HMAC provider, the provider + /// `create_test_settings` selects, recognizes as its own. + fn recognized_hmac_ec_id() -> String { + format!("hmac~{}", crate::test_support::tests::VALID_SYNTHETIC_ID) + } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn stateless_settings() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + settings + } + + fn signed_click_request(settings: &Settings, ec_id: &str) -> Request { + let tsurl = "https://cdn.example/a.png"; + let full = format!("{tsurl}?foo=1"); + let sig = crate::http_util::compute_encrypted_sha256_token(settings, &full); + let mut req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&foo=1&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + sig + ), + ); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC, + HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn click_ts_ec_param( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Option { + let resp = futures::executor::block_on(handle_first_party_click(settings, services, req)) + .expect("should redirect"); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|h| h.to_str().ok()) + .expect("Location header should be present and valid") + .to_owned(); + url::Url::parse(&loc) + .expect("Location should be a valid URL") + .query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + + #[test] + fn click_withholds_an_ec_id_the_provider_does_not_recognize() { + let settings = create_test_settings(); + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, FOREIGN_CODED_EC_ID), + ); + + assert_eq!( + param, None, + "a value carrying another deployment's provider code should not reach the click target" + ); + } + + #[test] + fn click_withholds_every_ec_id_in_a_stateless_deployment() { + let settings = stateless_settings(); + // The value is one the built-in provider would recognize, so only the + // absence of a selected provider can withhold it. + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, &recognized_hmac_ec_id()), + ); + + assert_eq!( + param, None, + "a deployment that mints no identifier should hand none to the click target" + ); + } + + #[test] + fn append_ec_id_forwards_only_what_the_provider_recognizes() { + let settings = create_test_settings(); + let services = noop_services(); + let recognized = recognized_hmac_ec_id(); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + Some(recognized.clone()), + "the deployment's own identifier should still reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(FOREIGN_CODED_EC_ID), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a foreign provider code should not reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &stateless_settings(), + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a stateless deployment should forward nothing to the origin" + ); + } + + fn request_with_ec_cookie(ec_id: &str) -> Request { + let mut req = build_http_request(Method::GET, "https://edge.example/page"); + req.headers_mut().insert( + http::header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={ec_id}")).expect("should build cookie header"), + ); + req + } + + fn ts_ec_param(url: &url::Url) -> Option { + url.query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + #[test] fn proxy_rebuild_adds_and_removes_params() { futures::executor::block_on(async { From e3ba5796b1191e01f991ff6c7f099f21bf435db3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:33:02 +0100 Subject: [PATCH 019/133] Record the cluster-count gap the identifier envelope opens Section 3 said the pre-epic IP-cluster prefix listing "continues unchanged". The listing does, but the key space it lists over does not. A fresh mint is keyed `hmac~.`, so the prefix `evaluate_cluster` derives is `hmac~` for a coded row while a legacy bare row still lists under `` alone. Prefix matching is anchored at the start of the key, so two rows for the same client IP that straddle the envelope never count each other and `cluster_size` under-reports while both populations coexist. The decision is to accept the undercount rather than bridge it, and the spec now says so along with the bound and the reasoning, and the prefix derivation in `evaluate_cluster` carries the same note so the next reader of that line is not surprised by it. The "gates nothing" half of the reasoning was checked rather than assumed. Every read of `cluster_size` in the workspace is a store, a log line, or the optional field in the identify response. The single read that reaches a branch is the cache short circuit in `evaluate_cluster` itself, which tests whether a value is stored, not what it is, so `Some(1)` and `Some(100000)` take the same path. There are no matches at all in the TypeScript or the integration tests. Two settings look like they gate on it and do not: `cluster_trust_threshold` (whose doc comment says entries at or below it "are treated as individual users for identity resolution") and `cluster_recheck_secs` are parsed and defaulted but have no readers anywhere in the code. They are noted here because they are what would make a reader believe the count is a control. They are outside this change; the unimplemented threshold wants an issue of its own. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/kv.rs:715 (thinking) --- crates/trusted-server-core/src/ec/kv.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 3572581ce..14f6f3487 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -712,6 +712,19 @@ impl KvIdentityGraph { } // Compute cluster size via prefix list. + // + // `ec_hash` takes everything before the first `.`, so a coded + // identifier yields `hmac~` and a legacy bare one yields + // ``. Prefix matching is anchored at the start of the key, so + // the two never see each other: while pre-epic bare cookies are still + // being read back, two rows for the same client IP that straddle the + // envelope each count only their own half and `cluster_size` + // under-reports. That is accepted, not a defect to work around here. + // The count is reported in identify responses and gates nothing, and + // bridging it would mean a second prefix scan on every request for the + // whole migration window. See section 3 of the pluggable-providers + // design. Anyone making this count gate a decision has to fix the + // bridge first. let hash_prefix = ec_hash(ec_id); let cluster_size = self.count_hash_prefix_keys(hash_prefix)?; From 292df1f816b7ef6712854e2078515709f7676d62 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:41:02 +0100 Subject: [PATCH 020/133] Consume the refused Report in the testlight egress tests The two egress tests added in the previous commit dropped the `Report` that `expect_err` returns on the floor. `Report` is `#[must_use]`, so building the library's test target warned, and clippy runs with `--all-targets -- -D warnings`, which would have failed the CI gate rather than only warning. --- crates/trusted-server-core/src/integrations/testlight.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index f6f54ee4a..e8de0aa00 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -511,7 +511,7 @@ mod tests { ); let settings = create_test_settings(); - testlight_integration() + let refused = testlight_integration() .handle( &settings, &services, @@ -519,6 +519,7 @@ mod tests { ) .await .expect_err("a foreign provider code should not be proxied upstream"); + drop(refused); assert!( stub.recorded_backend_names().is_empty(), @@ -541,7 +542,7 @@ mod tests { settings.ec.provider = None; settings.ec.providers.hmac = None; - testlight_integration() + let refused = testlight_integration() .handle( &settings, &services, @@ -549,6 +550,7 @@ mod tests { ) .await .expect_err("a deployment that mints no identifier should proxy none"); + drop(refused); assert!( stub.recorded_backend_names().is_empty(), From a3a5d578e46a028885280476036a8f876a58b528 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:45:40 +0100 Subject: [PATCH 021/133] Drop the request-evidence accessors that have no caller The spec's minimalism rule wants a production caller in the same change that introduces a method. `RequestInfo` arrived with seven accessors and only one of them, `client_ip`, is read by production code, in `HmacProvider::generate` at crates/trusted-server-core/src/ec/provider.rs. The other six had no non-test caller anywhere in the workspace on this branch as it stands: - `user_agent()` and `header_names()` had no caller at all, test or otherwise, beyond their two implementations. - `header()` and `query_param()` were called only by two test doubles in `ec/mod.rs`, both inside `#[cfg(test)]`, plus evidence.rs's own tests. - `path()` and `query()` were called only by evidence.rs's own tests, and `query()` by the default body of `query_param()`, which nothing called. Note the file is crates/trusted-server-core/src/evidence.rs; there is no `ec/evidence.rs`. The many `.path()`, `.query()` and `.header()` hits elsewhere in the workspace are `http::Uri`, `http::request::Builder` and the unrelated `http_util::RequestInfo` struct, which has `host` and `scheme` fields and none of these methods. All six are removed, along with everything that existed only to feed them: the `headers`, `path` and `query` fields and the `with_request_target` builder on both `OwnedRequestInfo` and `BorrowedRequestInfo`, the header snapshot argument of `OwnedRequestInfo::new`, `BorrowedRequestInfo::new` and the test-only `edge_cookie::generate_ec_id`, and the `request_headers` / `request_path` / `request_query` snapshot `EcContext` took at read time to fill them. Leaving state a provider can no longer read would be worse than the accessors themselves. Two test doubles went with them, `CookieCapturingProvider` and `EvidenceCapturingProvider`, along with the two tests that existed to prove the removed accessors carried cookies and query parameters. The third test that used `EvidenceCapturingProvider`, `a_provider_that_reads_no_client_ip_mints_when_the_host_has_none`, tests something else (a provider that needs no client IP still mints on a host that has none), so it stays, now with a `NoClientIpProvider` double that also asserts such a host passes the documented empty string rather than failing. The trait keeps its role as the seam. Its docs, the provider module docs and section 4 of the design now say that further evidence arrives as a defaulted accessor in the change that first reads it, rather than claiming a provider can already read headers, cookies, client hints and the URL. Probe: removing `client_ip` from the trait fails the library build at ec/provider.rs, where `HmacProvider::generate` reads it. Removing the other six failed nothing outside the tests deleted with them, which is the asymmetry this commit is about. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/evidence.rs:27 (thinking) --- crates/trusted-server-core/src/ec/mod.rs | 271 +++++------------- crates/trusted-server-core/src/ec/provider.rs | 10 +- crates/trusted-server-core/src/edge_cookie.rs | 24 +- crates/trusted-server-core/src/evidence.rs | 254 +++------------- 4 files changed, 116 insertions(+), 443 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 70093d4f6..90dfc06a4 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -159,16 +159,6 @@ pub struct EcContext { /// instead of being dropped by the built-in shape check. `None` when no /// provider is configured. selected_provider: Option>, - /// A snapshot of the request evidence a provider reads at generation time: - /// the request headers (so a provider can read cookies and client hints), and - /// the URL path and query string (so it can read request parameters). - /// Captured once at construction, and only when a provider is configured, so - /// a deployment with no Edge Cookie provider clones nothing. A provider reads - /// these through [`RequestInfo`](crate::evidence::RequestInfo) at generate - /// time. - request_headers: http::HeaderMap, - request_path: String, - request_query: String, /// Response headers a provider asked to set, captured during /// [`EcContext::generate_if_needed`] and applied to the response by EC /// finalization. Empty for providers that set no headers. @@ -237,23 +227,6 @@ impl EcContext { log::trace!("Existing EC ID found: {}", log_id(id)); } - // Snapshot the request evidence a provider reads at generation time (the - // headers, so it can read cookies and client hints, and the URL path and - // query, so it can read request parameters). Capture only when a provider - // is configured and no identifier already exists, so a no-provider - // deployment and a returning visitor clone nothing. Generation runs after - // the request body may be consumed, so the snapshot is owned. - let (request_headers, request_path, request_query) = - if selected_provider.is_some() && ec_value.is_none() { - ( - req.headers().clone(), - req.uri().path().to_owned(), - req.uri().query().unwrap_or_default().to_owned(), - ) - } else { - (http::HeaderMap::new(), String::new(), String::new()) - }; - // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -298,9 +271,6 @@ impl EcContext { geo_info: geo_info.cloned(), device_signals: None, selected_provider, - request_headers, - request_path, - request_query, response_headers: Vec::new(), }) } @@ -360,12 +330,11 @@ impl EcContext { /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the /// provider is supplied explicitly: the configured path builds it from /// settings, and tests pass one in to observe the [`IdentityInput`] a - /// provider receives. The request evidence captured at read time (client - /// IP, headers, and the URL path and query) is passed borrowed through - /// [`RequestInfo`](crate::evidence::RequestInfo), so a provider can read - /// cookies and request parameters at generate time; the built-ins read - /// only the client IP. The skip guards (existing EC, consent gate) - /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// provider receives. The request evidence captured at read time, the + /// normalized client IP, is passed borrowed through + /// [`RequestInfo`](crate::evidence::RequestInfo). The skip guards (existing + /// EC, consent gate) stay in + /// [`generate_if_needed`](Self::generate_if_needed). /// /// # Errors /// @@ -384,16 +353,11 @@ impl EcContext { let input = IdentityInput { consent: Some(&self.consent), }; - // Pass the request evidence captured at read time, borrowed: the client - // IP, the request headers (so a provider reads cookies and client hints), - // and the URL path and query (so it reads request parameters). A built-in - // provider reads only the client IP; a vendor provider reads what it - // needs through [`RequestInfo`]. - let request_info = BorrowedRequestInfo::new( - self.client_ip.as_deref().unwrap_or_default(), - Some(&self.request_headers), - ) - .with_request_target(&self.request_path, &self.request_query); + // Pass the request evidence captured at read time, borrowed. That is the + // normalized client IP, the only evidence a provider in this workspace + // reads today. `RequestInfo` is the seam, so further evidence arrives as + // a defaulted accessor in the change that first reads it. + let request_info = BorrowedRequestInfo::new(self.client_ip.as_deref().unwrap_or_default()); let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; // Check every response header the provider asked for against core's // reserved surface before any of them are kept. A provider may set its @@ -699,9 +663,6 @@ impl EcContext { geo_info: None, device_signals: None, selected_provider: None, - request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), response_headers: Vec::new(), } } @@ -726,9 +687,6 @@ impl EcContext { geo_info: None, device_signals: None, selected_provider: None, - request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), response_headers: Vec::new(), } } @@ -756,9 +714,6 @@ impl EcContext { geo_info: None, device_signals: None, selected_provider: None, - request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), response_headers: Vec::new(), } } @@ -784,7 +739,7 @@ pub(crate) fn current_timestamp() -> u64 { mod tests { use super::*; use crate::ec::provider::{EcProviderSelection, ProviderCode}; - use crate::evidence::{OwnedRequestInfo, RequestInfo}; + use crate::evidence::RequestInfo; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -803,67 +758,6 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } - /// A provider that records the `Cookie` header from the request info passed - /// to `generate`, so a test can prove request cookies reach a provider (a - /// client that stores values in cookies relies on this). - #[derive(Debug)] - struct CookieCapturingProvider { - seen_cookie: std::sync::Mutex>, - } - - impl EdgeCookieProvider for CookieCapturingProvider { - fn id(&self) -> &'static str { - "cookie-capturing" - } - - fn code(&self) -> ProviderCode { - ProviderCode::new("t0cc") - } - - fn generate( - &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - ) -> Result> { - let cookie = request_info.header("cookie").map(ToOwned::to_owned); - *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; - Ok(GeneratedEdgeCookie::default()) - } - } - - #[test] - fn a_provider_reads_request_cookies_from_the_request_info() { - // RequestInfo contract: a provider given request info that carries - // headers can read request cookies through it (a client that stores - // values in cookies relies on this). The organic generate path passes - // no header snapshot; a caller that has headers supplies them. - let mut headers = http::HeaderMap::new(); - headers.insert( - "cookie", - "client-id=abc123; ts-ec=xyz" - .parse() - .expect("should build a valid cookie header"), - ); - let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); - let provider = CookieCapturingProvider { - seen_cookie: std::sync::Mutex::new(None), - }; - - provider - .generate(&request_info, &IdentityInput::default()) - .expect("generation should succeed"); - - assert_eq!( - provider - .seen_cookie - .lock() - .expect("should lock seen cookie") - .as_deref(), - Some("client-id=abc123; ts-ec=xyz"), - "the provider should read the request cookies from the request info" - ); - } - /// A provider whose identifiers are opaque and deliberately not the /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier /// such as a signed envelope. It accepts any of its own non-empty @@ -962,83 +856,6 @@ mod tests { ); } - /// A provider that records the request query parameter `id` and the `Cookie` - /// header it is given at generate time, proving request evidence (parameters - /// and cookies) reaches a provider through the organic generate path. - #[derive(Debug, Default)] - struct EvidenceCapturingProvider { - seen: std::sync::Mutex>, - } - - impl EdgeCookieProvider for EvidenceCapturingProvider { - fn id(&self) -> &'static str { - "evidence" - } - - fn code(&self) -> ProviderCode { - ProviderCode::new("t0ev") - } - - fn generate( - &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - ) -> Result> { - let query_id = request_info.query_param("id").unwrap_or_default(); - let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); - *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); - Ok(GeneratedEdgeCookie { - id: Some("evidence-ec".to_owned()), - response_headers: Vec::new(), - }) - } - - fn accepts_id(&self, value: &str) -> bool { - !value.is_empty() - } - } - - #[test] - fn generate_passes_request_parameters_and_cookies_to_the_provider() { - use crate::platform::test_support::noop_services_with_ec_provider; - - let provider = Arc::new(EvidenceCapturingProvider::default()); - let mut settings = create_test_settings(); - settings.ec.provider = Some(EcProviderSelection::from("evidence")); - - // A request carrying a query parameter and a (non-EC) cookie, with no - // existing `ts-ec` cookie so the generate path runs. - let req = Request::builder() - .method("GET") - .uri("http://example.com/page?id=abc123&debug=1") - .header("cookie", "client-id=xyz789") - .body(EdgeBody::empty()) - .expect("should build request"); - - let services = noop_services_with_ec_provider(provider.clone()); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) - .expect("should read EC context"); - ec.generate_if_needed(&settings, None) - .expect("should run generation"); - - let seen = provider - .seen - .lock() - .expect("should lock seen evidence") - .clone(); - assert_eq!( - seen, - Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), - "the provider should read the request query parameter and cookies at generate time" - ); - assert_eq!( - ec.ec_value(), - Some("t0ev~evidence-ec"), - "the identifier the provider minted should be committed under its code" - ); - } - /// A provider that mints an opaque, mixed-case, non-HMAC identifier at the /// edge, so a test can prove such an identifier persists to the KV identity /// graph under its own value as the key. @@ -1116,23 +933,56 @@ mod tests { ); } + /// A provider that needs no client IP, recording the value it was given so + /// a test can prove a host that cannot determine one passes the documented + /// unavailable value rather than failing the request. + #[derive(Debug, Default)] + struct NoClientIpProvider { + seen_client_ip: std::sync::Mutex>, + } + + impl EdgeCookieProvider for NoClientIpProvider { + fn id(&self) -> &'static str { + "no-client-ip" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ni") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + *self + .seen_client_ip + .lock() + .expect("should lock the seen client IP") = + Some(request_info.client_ip().to_owned()); + Ok(GeneratedEdgeCookie { + id: Some("no-ip-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + #[test] fn a_provider_that_reads_no_client_ip_mints_when_the_host_has_none() { use crate::platform::test_support::noop_services_with_ec_provider_without_client_ip; // The requirement for a client IP belongs to the provider that uses - // one, not to core. A provider deriving identity from the request - // query and cookies runs on a host that cannot determine a client IP, - // and receives the documented unavailable value, the empty string. - let provider = Arc::new(EvidenceCapturingProvider::default()); + // one, not to core. A provider that derives identity from something + // else runs on a host that cannot determine a client IP, and receives + // the documented unavailable value, the empty string. + let provider = Arc::new(NoClientIpProvider::default()); let mut settings = create_test_settings(); - settings.ec.provider = Some(EcProviderSelection::from("evidence")); - let req = Request::builder() - .method("GET") - .uri("http://example.com/page?id=abc123") - .header("cookie", "client-id=xyz789") - .body(EdgeBody::empty()) - .expect("should build request"); + settings.ec.provider = Some(EcProviderSelection::from("no-client-ip")); + let req = create_test_request(&[]); let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); let geo = non_regulated_geo(); @@ -1148,9 +998,18 @@ mod tests { .expect("a provider that reads no client IP should still mint"); assert_eq!( ec.ec_value(), - Some("t0ev~evidence-ec"), + Some("t0ni~no-ip-ec"), "the identifier should be committed with no client IP available" ); + assert_eq!( + provider + .seen_client_ip + .lock() + .expect("should lock the seen client IP") + .as_deref(), + Some(""), + "a host with no client IP should pass the empty string, not fail the call" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index d833eaea8..4db672919 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -11,10 +11,12 @@ //! //! Request evidence reaches a provider at call time rather than at //! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], -//! which carries the normalized client IP, the User-Agent and the request -//! headers, for the life of the call, alongside an [`IdentityInput`] holding -//! the request's gating context. A provider reads what it needs and retains -//! nothing, so no per-request snapshot is stored or cloned. +//! which carries the normalized client IP, for the life of the call, alongside +//! an [`IdentityInput`] holding the request's gating context. A provider reads +//! what it needs and retains nothing, so no per-request snapshot is stored or +//! cloned. `RequestInfo` is the seam rather than a fixed parameter list, so a +//! provider needing further evidence gains a defaulted accessor for it in the +//! change that first reads it. //! //! [`HmacProvider`] is the built-in server-side implementation. It derives the //! identifier from the client IP using HMAC over the configured passphrase, the diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index f742f8997..67ffc0aba 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -26,9 +26,8 @@ use crate::settings::Settings; /// Routes through the pluggable provider model: the active `[ec] provider` /// selection decides the outcome. Returns `Ok(None)` when no provider is /// configured, so Trusted Server runs statelessly and mints no Edge Cookie. -/// `request_headers` lets a provider that derives identity from request -/// evidence read it; the built-in HMAC provider ignores it and uses only the -/// normalized client IP. +/// The built-in HMAC provider derives the identifier from the normalized client +/// IP alone. /// /// # Errors /// @@ -40,7 +39,6 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, - request_headers: Option<&http::HeaderMap>, ) -> Result, Report> { // Fall back to "unknown" when the client IP is unavailable (for example in // local testing). All such requests share the same HMAC base; the random @@ -58,9 +56,9 @@ pub fn generate_ec_id( return Ok(None); }; - // The provider reads request data (for example the client IP) borrowed at - // call time, so nothing is cloned. - let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The provider reads request data (the client IP) borrowed at call time, so + // nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip); // The publisher path gates creation on the request's consent context at // the call site, and the built-in provider reads neither that result nor // the consent context, so @@ -193,7 +191,7 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( } // If no existing EC ID found, generate a fresh one through the provider. - let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + let ec_id = generate_ec_id(settings, services)?; if ec_id.is_some() { log::trace!("No existing EC ID found; generated a fresh EC ID"); } @@ -236,7 +234,7 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) .expect("should generate EC ID via edge_cookie") .expect("should configure the hmac provider in test settings"); let passphrase = settings @@ -302,7 +300,7 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services(), None) + let ec_id = generate_ec_id(&settings, &noop_services()) .expect("should generate EC ID") .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); @@ -318,7 +316,7 @@ mod tests { // No provider selected: Trusted Server runs statelessly. settings.ec.provider = None; - let id = generate_ec_id(&settings, &noop_services(), None) + let id = generate_ec_id(&settings, &noop_services()) .expect("generation should not error when no provider is configured"); assert!( id.is_none(), @@ -331,10 +329,10 @@ mod tests { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) .expect("should generate EC ID with client IP") .expect("should configure the hmac provider in test settings"); - let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + let id_without_ip = generate_ec_id(&settings, &noop_services()) .expect("should generate EC ID without client IP") .expect("should configure the hmac provider in test settings"); diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 82eb408ea..fc198c004 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -11,104 +11,38 @@ //! only when snapshotted, so an implementation owns its data where needed. //! [`BorrowedRequestInfo`] is the borrowed view core builds on the request //! path, and [`OwnedRequestInfo`] is the built-in owned snapshot. - -use http::HeaderMap; +//! +//! [`RequestInfo`] carries the evidence a provider in this workspace actually +//! reads, which today is the normalized client IP. It is the seam rather than a +//! fixed parameter list, so an accessor for further evidence (the User-Agent, +//! request headers, the request target) is added as a defaulted method in the +//! change that first reads it, without breaking any existing implementation. /// Read-only access to the current request's basic information. /// -/// The request data any host can supply: the normalized client IP, the -/// User-Agent, and request headers. A provider receives it by reference at call -/// time (`generate`/`detect`), reads what it needs, and does not retain it. +/// A provider receives it by reference at call time (`generate`), reads what it +/// needs, and does not retain it. pub trait RequestInfo: Send + Sync + core::fmt::Debug { /// The normalized client IP, or `""` when the host cannot determine it. fn client_ip(&self) -> &str; - - /// The `User-Agent` header value, or `""` when absent. - fn user_agent(&self) -> &str; - - /// An arbitrary request header by name (case-insensitive), or `None`. - /// - /// Request cookies are read through this, from the `Cookie` header (a - /// provider that stores values in cookies parses them from it). - fn header(&self, name: &str) -> Option<&str>; - - /// The names of all request headers present, for a provider that enumerates - /// evidence (for example to forward client hints). The default is empty. - fn header_names(&self) -> Vec<&str> { - Vec::new() - } - - /// The request path (the URL path, without the query string), or `""` when - /// request info was built without a URL. - /// - /// A provider reads the request target through this together with - /// [`query`](Self::query); `RequestInfo` is the evidence abstraction, so more - /// request accessors can be added here (as defaulted methods) without - /// breaking existing implementations. - fn path(&self) -> &str { - "" - } - - /// The raw request query string (the part after `?`, without the leading - /// `?`), or `""` when the request carried none. - /// - /// A provider reads request parameters through this, or the - /// [`query_param`](Self::query_param) convenience. The default is empty, for - /// request info built without a URL. - fn query(&self) -> &str { - "" - } - - /// The first value of query parameter `name`, percent-decoded, or `None` - /// when the parameter is absent. - /// - /// Parses [`query`](Self::query) with `application/x-www-form-urlencoded` - /// rules, matching how the browser encodes query parameters. - fn query_param(&self, name: &str) -> Option { - url::form_urlencoded::parse(self.query().as_bytes()) - .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) - } } /// An owned [`RequestInfo`] built from a request snapshot. /// -/// Owns the client IP and a header snapshot, for a context that cannot borrow -/// the live request for the duration of the call. The request path uses -/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future -/// host whose request data cannot be borrowed. +/// Owns its data, for a context that cannot borrow the live request for the +/// duration of the call. The request path uses [`BorrowedRequestInfo`]; this +/// owned variant serves tests and any future host whose request data cannot be +/// borrowed. #[derive(Debug, Default, Clone)] pub struct OwnedRequestInfo { client_ip: String, - headers: HeaderMap, - path: String, - query: String, } impl OwnedRequestInfo { - /// Builds owned request info from the client IP and a header snapshot. - /// - /// The request target ([`path`](RequestInfo::path) and - /// [`query`](RequestInfo::query)) is empty; attach it with - /// [`with_request_target`](Self::with_request_target) when the caller has the - /// URL. - #[must_use] - pub fn new(client_ip: String, headers: HeaderMap) -> Self { - Self { - client_ip, - headers, - path: String::new(), - query: String::new(), - } - } - - /// Attaches the request target (URL path and query string) to this snapshot, - /// so a provider can read request parameters through - /// [`query_param`](RequestInfo::query_param). + /// Builds owned request info from the normalized client IP. #[must_use] - pub fn with_request_target(mut self, path: String, query: String) -> Self { - self.path = path; - self.query = query; - self + pub fn new(client_ip: String) -> Self { + Self { client_ip } } } @@ -116,72 +50,24 @@ impl RequestInfo for OwnedRequestInfo { fn client_ip(&self) -> &str { &self.client_ip } - - fn user_agent(&self) -> &str { - self.headers - .get(http::header::USER_AGENT) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - } - - fn header(&self, name: &str) -> Option<&str> { - self.headers.get(name).and_then(|value| value.to_str().ok()) - } - - fn header_names(&self) -> Vec<&str> { - self.headers.keys().map(http::HeaderName::as_str).collect() - } - - fn path(&self) -> &str { - &self.path - } - - fn query(&self) -> &str { - &self.query - } } /// A borrowed [`RequestInfo`] over the live request, with no allocation. /// -/// The composition root builds one per request from the normalized client IP and -/// an optional borrow of the request headers, then passes it to a provider by -/// shared reference at call time (`generate`/`detect`). It borrows rather than -/// owns, so it must not outlive the request. A provider reads it during the call -/// and does not retain it, so no per-request `HeaderMap` clone is needed. +/// The composition root builds one per request from the normalized client IP, +/// then passes it to a provider by shared reference at call time (`generate`). +/// It borrows rather than owns, so it must not outlive the request. A provider +/// reads it during the call and does not retain it. #[derive(Debug)] pub struct BorrowedRequestInfo<'a> { client_ip: &'a str, - headers: Option<&'a HeaderMap>, - path: &'a str, - query: &'a str, } impl<'a> BorrowedRequestInfo<'a> { - /// Borrows request info from the client IP and optional request headers. - /// - /// Pass `None` for headers on a path that only needs the client IP. The - /// request target ([`path`](RequestInfo::path) and - /// [`query`](RequestInfo::query)) is empty; attach it with - /// [`with_request_target`](Self::with_request_target) when the caller has the - /// URL. - #[must_use] - pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { - Self { - client_ip, - headers, - path: "", - query: "", - } - } - - /// Attaches the borrowed request target (URL path and query string), so a - /// provider can read request parameters through - /// [`query_param`](RequestInfo::query_param). + /// Borrows request info from the normalized client IP. #[must_use] - pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { - self.path = path; - self.query = query; - self + pub fn new(client_ip: &'a str) -> Self { + Self { client_ip } } } @@ -189,107 +75,35 @@ impl RequestInfo for BorrowedRequestInfo<'_> { fn client_ip(&self) -> &str { self.client_ip } - - fn user_agent(&self) -> &str { - self.headers - .and_then(|headers| headers.get(http::header::USER_AGENT)) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - } - - fn header(&self, name: &str) -> Option<&str> { - self.headers - .and_then(|headers| headers.get(name)) - .and_then(|value| value.to_str().ok()) - } - - fn header_names(&self) -> Vec<&str> { - self.headers - .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) - .unwrap_or_default() - } - - fn path(&self) -> &str { - self.path - } - - fn query(&self) -> &str { - self.query - } } #[cfg(test)] mod tests { use super::*; - fn headers_with_cookie() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - "cookie", - "client-id=abc123; ts-ec=xyz" - .parse() - .expect("should parse cookie header"), - ); - headers - } - #[test] - fn query_param_decodes_and_selects_the_first_value() { - let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) - .with_request_target("/page".to_owned(), "id=a%20b&id=second&flag=1".to_owned()); + fn owned_request_info_reports_the_client_ip_it_was_built_with() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned()); - assert_eq!( - info.query_param("id").as_deref(), - Some("a b"), - "should percent-decode and return the first value for a repeated key" - ); - assert_eq!(info.query_param("flag").as_deref(), Some("1")); - assert_eq!( - info.query_param("missing"), - None, - "an absent parameter should be None" - ); + assert_eq!(info.client_ip(), "203.0.113.5"); } #[test] - fn path_and_query_accessors_return_the_request_target() { - let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) - .with_request_target("/a/b".to_owned(), "x=1".to_owned()); - assert_eq!(info.path(), "/a/b"); - assert_eq!(info.query(), "x=1"); - } + fn owned_request_info_defaults_to_no_client_ip() { + let info = OwnedRequestInfo::default(); - #[test] - fn request_info_defaults_to_an_empty_target() { - let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), HeaderMap::new()); - assert_eq!(info.path(), "", "path should default to empty"); - assert_eq!(info.query(), "", "query should default to empty"); - assert_eq!( - info.query_param("id"), - None, - "query_param over an empty query should be None" - ); - } - - #[test] - fn a_provider_reads_cookies_from_the_header() { - let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), headers_with_cookie()); assert_eq!( - info.header("cookie"), - Some("client-id=abc123; ts-ec=xyz"), - "cookies are read through the Cookie header" + info.client_ip(), + "", + "a host that cannot determine a client IP reports an empty one" ); } #[test] - fn borrowed_request_info_exposes_the_same_target() { - let headers = headers_with_cookie(); - let info = BorrowedRequestInfo::new("203.0.113.5", Some(&headers)) - .with_request_target("/page", "id=abc123"); + fn borrowed_request_info_reports_the_same_client_ip() { + let client_ip = "203.0.113.5".to_owned(); + let info = BorrowedRequestInfo::new(&client_ip); - assert_eq!(info.path(), "/page"); - assert_eq!(info.query(), "id=abc123"); - assert_eq!(info.query_param("id").as_deref(), Some("abc123")); - assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); + assert_eq!(info.client_ip(), "203.0.113.5"); } } From b70ddc08e96810a93e93edbd7a6ba58f731e19ec Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 09:42:21 +0100 Subject: [PATCH 022/133] Collapse the EC provider selector to statelessness and a named provider Every Edge Cookie provider goes through one mechanism and none of them is special, so the selector no longer carries a variant for the built-in HMAC provider. `EcProviderSelection` is now `None` for explicit statelessness and `Named(String)` for a provider chosen by name, and `hmac` is an ordinary name in the same open-ended namespace a vendor crate names its own provider from. A variant per provider wrote the special case into the type, so every match on it had to know that one provider was different, and the built-in provider is due to become a vendor-supplied module rather than living in core. The one place that still knows `hmac` is built into core is the resolution in `build_provider`, lifted into `resolve_named_provider` and commented to say that it goes when the built-in provider becomes a module, after which `hmac` resolves through the injected path like any other name. Nothing else branches on whether a name is built in. `Ec::validate_provider_selection` is now a name lookup through the new `EcProviders::has_block`, and the unreferenced-block check reads the new `EcProviders::configured_keys` rather than pushing `hmac` in by hand, which also drops `has_vendor` and `vendor_keys`, both of which only answered for names that are not built in. `EcProviderSelection::HMAC_KEY` becomes the module-level `HMAC_PROVIDER_KEY` beside `HMAC_PROVIDER_CODE`, because the selection type should not name any one provider. The configuration surface does not change. `[ec] provider = "none"`, `"hmac"` and any vendor key parse to the same behavior and are written back as exactly the same string, which the round-trip test now proves on the serialized scalar itself rather than only on the surrounding document text. This is not the reviewer's finding about scattered string literals, which the typed selector already fixed. It is the project's own rule that no provider is special. --- crates/trusted-server-core/src/ec/provider.rs | 217 +++++++++++------- crates/trusted-server-core/src/settings.rs | 96 ++++---- 2 files changed, 184 insertions(+), 129 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 4db672919..0503995ed 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -39,15 +39,20 @@ use super::generation; /// The Edge Cookie identity provider a deployment has selected. /// /// Deserialized from the `[ec] provider` string, and serialized back to the -/// same string, so the configuration surface is unchanged. Vendor keys are -/// open-ended (a vendor crate names its own), so any key that is not a -/// built-in becomes [`Vendor`](Self::Vendor) rather than a parse failure, and -/// whether the deployment can actually supply it is decided by -/// [`build_provider`]. +/// same string, so the configuration surface is unchanged. Provider names are +/// open-ended (a vendor crate names its own), so every name other than the +/// explicit `"none"` becomes [`Named`](Self::Named) rather than a parse +/// failure, and whether the deployment can actually supply that provider is +/// decided by [`build_provider`]. /// -/// This is the one place the provider keys are spelled. Everything that needs -/// to ask which provider is selected matches on this rather than comparing -/// string literals. +/// No individual provider has a variant of its own, the one still built into +/// core included. Every provider is selected the same way, by name, so no +/// caller can be written around one provider being different, and moving the +/// built-in provider out into its own module changes nothing here. +/// +/// This is the one place the selector is spelled. Everything that needs to ask +/// which provider is selected matches on this rather than comparing string +/// literals. #[derive(Debug, Clone, Eq, Hash, PartialEq, Deserialize, Serialize)] #[serde(from = "String", into = "String")] pub enum EcProviderSelection { @@ -56,30 +61,23 @@ pub enum EcProviderSelection { /// configured. None, - /// The built-in HMAC provider, spelled `"hmac"`, configured by - /// `[ec.providers.hmac]`. - Hmac, - - /// A vendor or host provider the adapter injects, named by its own key and - /// configured by the matching `[ec.providers.]` block. - Vendor(String), + /// A provider selected by name, configured by the matching + /// `[ec.providers.]` block. [`build_provider`] resolves the name to + /// an implementation, whether that implementation is built into core or + /// injected by the adapter. + Named(String), } impl EcProviderSelection { /// The configuration spelling of explicit statelessness. pub const NONE_KEY: &'static str = "none"; - /// The configuration spelling of the built-in HMAC provider, which is also - /// [`HmacProvider::id`]'s return value and [`HMAC_PROVIDER_CODE`]'s text. - pub const HMAC_KEY: &'static str = "hmac"; - /// The configuration key this selection is written as. #[must_use] pub fn key(&self) -> &str { match self { Self::None => Self::NONE_KEY, - Self::Hmac => Self::HMAC_KEY, - Self::Vendor(key) => key, + Self::Named(key) => key, } } } @@ -88,8 +86,7 @@ impl From<&str> for EcProviderSelection { fn from(key: &str) -> Self { match key { EcProviderSelection::NONE_KEY => Self::None, - EcProviderSelection::HMAC_KEY => Self::Hmac, - other => Self::Vendor(other.to_owned()), + other => Self::Named(other.to_owned()), } } } @@ -98,8 +95,7 @@ impl From for EcProviderSelection { fn from(key: String) -> Self { match key.as_str() { EcProviderSelection::NONE_KEY => Self::None, - EcProviderSelection::HMAC_KEY => Self::Hmac, - _ => Self::Vendor(key), + _ => Self::Named(key), } } } @@ -108,19 +104,27 @@ impl From for String { fn from(selection: EcProviderSelection) -> Self { match selection { EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), - EcProviderSelection::Hmac => EcProviderSelection::HMAC_KEY.to_owned(), - EcProviderSelection::Vendor(key) => key, + EcProviderSelection::Named(key) => key, } } } +/// The configuration name of the provider still built into core. +/// +/// The name lives in the same open-ended namespace every vendor provider name +/// comes from, and nothing branches on it outside the resolution in +/// [`build_provider`]. It is also [`HmacProvider::id`]'s return value and +/// [`HMAC_PROVIDER_CODE`]'s text. It goes with that resolution arm when the +/// built-in provider becomes a module of its own. +pub const HMAC_PROVIDER_KEY: &str = "hmac"; + /// The registry code of the built-in HMAC provider. /// -/// The same text as [`EcProviderSelection::HMAC_KEY`], but a different role: -/// this is the `{code}~` namespace stamped on every identifier the built-in -/// provider mints, and it is what [`generation`] matches when it decides -/// whether an enveloped identifier is one of its own. -pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(EcProviderSelection::HMAC_KEY); +/// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the +/// `{code}~` namespace stamped on every identifier the built-in provider +/// mints, and it is what [`generation`] matches when it decides whether an +/// enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY); /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// @@ -368,9 +372,7 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { match split_provider_code(full) { (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), - (None, value) => { - provider.id() == EcProviderSelection::HMAC_KEY && provider.accepts_id(value) - } + (None, value) => provider.id() == HMAC_PROVIDER_KEY && provider.accepts_id(value), } } @@ -443,7 +445,7 @@ impl<'a> AcceptedProviders<'a> { let (code, _) = split_provider_code(full); self.readers.iter().copied().find(|provider| match code { Some(code) => provider.code().as_str() == code, - None => provider.id() == EcProviderSelection::HMAC_KEY, + None => provider.id() == HMAC_PROVIDER_KEY, }) } @@ -578,7 +580,7 @@ impl HmacProvider { impl EdgeCookieProvider for HmacProvider { fn id(&self) -> &'static str { - EcProviderSelection::HMAC_KEY + HMAC_PROVIDER_KEY } fn code(&self) -> ProviderCode { @@ -616,11 +618,10 @@ impl EdgeCookieProvider for HmacProvider { /// /// # Errors /// -/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot -/// be built: `"hmac"` without an `[ec.providers.hmac]` block, or a vendor key -/// this deployment's adapter does not inject. Both fail loudly rather than -/// leaving the deployment running stateless under a selector that says -/// otherwise. +/// Returns [`TrustedServerError::EdgeCookie`] when the named provider cannot be +/// built: a built-in name whose configuration block is missing, or a name this +/// deployment's adapter does not inject. Both fail loudly rather than leaving +/// the deployment running stateless under a selector that says otherwise. pub fn build_provider( ec: &Ec, injected: Option>, @@ -631,45 +632,68 @@ pub fn build_provider( let provider: Option> = match selection { // Explicit statelessness: the same meaning as omitting the selector. EcProviderSelection::None => None, - // Settings validation rejects `hmac` with no block before this runs, so - // reaching here means the two checks have drifted apart. Stopping is - // the only safe answer: returning `Ok(None)` would run the deployment - // stateless under a selector that says it has an identity provider. - EcProviderSelection::Hmac => { - let config = ec.providers.hmac.as_ref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { - message: "Edge Cookie provider `hmac` is selected but has no \ - `[ec.providers.hmac]` configuration" - .to_owned(), - }) - })?; - Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) - } - // A vendor key names a vendor or host provider the adapter injects - // through [`RuntimeServices`](crate::platform::RuntimeServices), the same - // seam the device and geo providers use, so core never names a vendor. - // The injected provider is used when its own id matches the selected key, - // and its `[ec.providers.]` block is read by the adapter that built - // it. A selected key with no matching injected provider is a deployment - // error: fail loudly rather than silently running stateless. - EcProviderSelection::Vendor(key) => { - let provider = injected - .filter(|provider| provider.id() == key) - .map(|provider| Box::new(SharedProvider(provider)) as _); - if provider.is_none() { - return Err(Report::new(TrustedServerError::EdgeCookie { - message: format!( - "Edge Cookie provider `{key}` is selected but this deployment's \ - adapter does not provide it" - ), - })); - } - provider - } + // Every provider is named, and this is the one place a name is resolved + // to an implementation. Nothing else in the codebase asks whether a + // name is built in. + EcProviderSelection::Named(key) => Some(resolve_named_provider(key, ec, injected)?), }; Ok(provider) } +/// Resolves one provider name to its implementation. +/// +/// A name is looked for among the providers built into core first, and is +/// otherwise the name of a provider the adapter injects through +/// [`RuntimeServices`](crate::platform::RuntimeServices), the same seam the +/// device and geo providers use, so core never names a vendor. The injected +/// provider is used when its own id matches the name, and its +/// `[ec.providers.]` block is read by the adapter that built it. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the name matches no provider +/// this deployment can build, or when a built-in name has no configuration +/// block. Both fail loudly rather than silently running stateless. +fn resolve_named_provider( + key: &str, + ec: &Ec, + injected: Option>, +) -> Result, Report> { + // The only place that knows a provider is built into core rather than + // supplied as a module. It disappears, along with `HMAC_PROVIDER_KEY`, + // when the HMAC provider becomes a module like every other provider, after + // which `hmac` resolves through the injected path below and nothing else + // changes. + // + // Settings validation rejects a built-in name with no block before this + // runs, so reaching the error means the two checks have drifted apart. + // Stopping is the only safe answer: returning no provider would run the + // deployment stateless under a selector that says it has an identity + // provider. + if key == HMAC_PROVIDER_KEY { + let config = ec.providers.hmac.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` is selected but has no \ + `[ec.providers.hmac]` configuration" + .to_owned(), + }) + })?; + return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); + } + + injected + .filter(|provider| provider.id() == key) + .map(|provider| Box::new(SharedProvider(provider)) as Box) + .ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{key}` is selected but this deployment's \ + adapter does not provide it" + ), + }) + }) +} + /// Checks once, at startup, that this deployment can build the provider named /// by the `[ec] provider` selector. /// @@ -933,8 +957,11 @@ mod tests { // working and a config push does not rewrite the selector. for (key, expected) in [ (EcProviderSelection::NONE_KEY, EcProviderSelection::None), - (EcProviderSelection::HMAC_KEY, EcProviderSelection::Hmac), - ("acme", EcProviderSelection::Vendor("acme".to_owned())), + ( + HMAC_PROVIDER_KEY, + EcProviderSelection::Named(HMAC_PROVIDER_KEY.to_owned()), + ), + ("acme", EcProviderSelection::Named("acme".to_owned())), ] { let ec: Ec = toml::from_str(&format!("provider = \"{key}\"")) .expect("should parse the [ec] section"); @@ -943,12 +970,36 @@ mod tests { Some(&expected), "`{key}` should select the provider it names" ); + assert_eq!( + expected.key(), + key, + "`{key}` should report itself under the key it was written as" + ); + + // The serialized form is the string itself, byte for byte, so an + // operator configuration written before the selector was typed + // parses and is written back identically. + let value = + toml::Value::try_from(expected.clone()).expect("should serialize the selection"); + assert_eq!( + value, + toml::Value::String(key.to_owned()), + "`{key}` should serialize to exactly its own string" + ); let written = toml::to_string(&ec).expect("should serialize the [ec] section"); assert!( written.contains(&format!("provider = \"{key}\"")), "`{key}` should be written back unchanged, got: {written}" ); + + // A full round trip through the document leaves the same choice. + let reparsed: Ec = toml::from_str(&written).expect("should reparse the [ec] section"); + assert_eq!( + reparsed.provider.as_ref(), + Some(&expected), + "`{key}` should survive a serialize and parse round trip" + ); } } @@ -972,7 +1023,7 @@ mod tests { passphrase: test_passphrase(), }); let hmac = Ec { - provider: Some(EcProviderSelection::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), providers, ..Ec::default() }; @@ -981,7 +1032,7 @@ mod tests { .expect("the hmac selection should yield a provider"); assert_eq!( built.id(), - EcProviderSelection::HMAC_KEY, + HMAC_PROVIDER_KEY, "`hmac` should select the built-in provider" ); assert_eq!( @@ -993,7 +1044,7 @@ mod tests { // An arbitrary vendor key selects the provider the adapter injected // under that same key. let vendor = Ec { - provider: Some(EcProviderSelection::Vendor("acme".to_owned())), + provider: Some(EcProviderSelection::Named("acme".to_owned())), ..Ec::default() }; let built = build_provider(&vendor, Some(Arc::new(VendorProvider))) @@ -1132,7 +1183,7 @@ mod tests { // reach the seam. If the two checks ever drift apart, `build_provider` // must still stop rather than hand back a stateless deployment. let ec = Ec { - provider: Some(EcProviderSelection::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), ..Ec::default() }; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a39a8ff87..5a5a55f3c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,7 +20,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; -use crate::ec::provider::EcProviderSelection; +use crate::ec::provider::{EcProviderSelection, HMAC_PROVIDER_KEY}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -627,33 +627,27 @@ impl Ec { return Ok(()); }; - let (key, configured) = match selection { - // `"none"` is explicit statelessness: the same meaning as omitting - // the selector, spelled out. It is subject to the same rule that no - // provider blocks may be left configured. - EcProviderSelection::None => { - if !self.providers.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "[ec] provider = \"none\" selects stateless operation, but \ - [ec.providers.*] blocks are configured. Remove the blocks, or \ - select the provider they configure" - .to_owned(), - })); - } - return Ok(()); - } - EcProviderSelection::Hmac => { - (EcProviderSelection::HMAC_KEY, self.providers.hmac.is_some()) - } - // A vendor or host provider the adapter injects is configured when - // its `[ec.providers.]` block is present. The adapter validates - // the block's own contents when it builds the provider. - EcProviderSelection::Vendor(vendor_key) => { - (vendor_key.as_str(), self.providers.has_vendor(vendor_key)) + // `"none"` is explicit statelessness: the same meaning as omitting the + // selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + let EcProviderSelection::Named(key) = selection else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); } + return Ok(()); }; - if !configured { + // Every provider is configured by the `[ec.providers.]` block that + // carries its own name, so the check is the same lookup for all of + // them. A provider the adapter injects has the contents of its block + // validated by that adapter when it builds the provider. + let key = key.as_str(); + if !self.providers.has_block(key) { return Err(Report::new(TrustedServerError::Configuration { message: format!( "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" @@ -664,15 +658,12 @@ impl Ec { // Every configured block must be the selected one. An unreferenced // block is almost always a mistake (a mistyped selector or a stale // block), and accepting it silently invites configuration drift. - let mut unreferenced: Vec = Vec::new(); - if self.providers.hmac.is_some() && !matches!(selection, EcProviderSelection::Hmac) { - unreferenced.push(EcProviderSelection::HMAC_KEY.to_owned()); - } - for vendor_key in self.providers.vendor_keys() { - if vendor_key != key { - unreferenced.push(vendor_key.to_owned()); - } - } + let unreferenced: Vec = self + .providers + .configured_keys() + .filter(|configured| *configured != key) + .map(str::to_owned) + .collect(); if unreferenced.is_empty() { Ok(()) } else { @@ -730,7 +721,7 @@ impl Ec { "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" ); - self.provider = Some(EcProviderSelection::Hmac); + self.provider = Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)); self.providers.hmac = Some(HmacProviderConfig { passphrase }); Ok(()) } @@ -774,15 +765,28 @@ impl EcProviders { self.vendor.get(key) } - /// Whether a vendor provider configuration block is present for `key`. + /// Whether a `[ec.providers.]` block is present for `key`. + /// + /// The answer is the same question for every provider, whichever crate + /// supplies it, so nothing calling this has to know which providers are + /// built into core. #[must_use] - pub fn has_vendor(&self, key: &str) -> bool { - self.vendor.contains_key(key) + pub fn has_block(&self, key: &str) -> bool { + self.configured_keys().any(|configured| configured == key) } - /// The keys of the configured vendor provider blocks. - pub(crate) fn vendor_keys(&self) -> impl Iterator { - self.vendor.keys().map(String::as_str) + /// The keys of every configured `[ec.providers.]` block. + /// + /// The typed built-in block is reported under the name it is configured + /// with, so it appears alongside the vendor blocks rather than being + /// counted separately by each caller. This is the one place that mapping is + /// made, and it goes away when the built-in provider becomes a module and + /// its block joins the others. + pub(crate) fn configured_keys(&self) -> impl Iterator { + self.hmac + .iter() + .map(|_| HMAC_PROVIDER_KEY) + .chain(self.vendor.keys().map(String::as_str)) } /// Whether any provider configuration block is present. @@ -792,7 +796,7 @@ impl EcProviders { /// would otherwise silently run stateless. #[must_use] pub fn is_empty(&self) -> bool { - self.hmac.is_none() && self.vendor.is_empty() + self.configured_keys().next().is_none() } } @@ -4617,7 +4621,7 @@ mod tests { assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( settings.ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "test settings should select the hmac EC provider" ); let Some(hmac) = &settings.ec.providers.hmac else { @@ -5066,7 +5070,7 @@ mod tests { .expect("should migrate the deprecated form"); assert_eq!( ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "the deprecated passphrase should select the hmac provider" ); assert_eq!( @@ -5130,7 +5134,7 @@ mod tests { .expect("a legacy passphrase of adequate length should still start"); assert_eq!( settings.ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "an adequate legacy passphrase should still select the hmac provider" ); assert_eq!( @@ -5228,7 +5232,7 @@ mod tests { fn legacy_passphrase_alongside_provider_config_is_rejected() { let mut ec = Ec { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), - provider: Some(EcProviderSelection::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), ..Ec::default() }; let err = ec From 5da52c81a3d046e9c691b225f18c56f4e77b7e2b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 13:12:38 +0100 Subject: [PATCH 023/133] Accumulate provider response headers instead of replacing the origin's The response EC finalization edits is the finished one, so it already carries whatever the publisher's origin returned. The provider-header loop used `HeaderMap::insert`, which drops every existing value for that name, so a provider setting one evidence cookie deleted every `Set-Cookie` the origin had written, a publisher's session and sign-in cookies included, and a provider setting `Vary` deleted the origin's. `response_headers` is a list of pairs precisely so a provider can set more than one cookie, and `insert` collapsed those too. The rule, written out on the new `apply_provider_response_headers`, is that this seam is additive. A provider only ever adds evidence about the request, it never corrects the origin's output, so core has no grounds to discard a value it did not write. `Set-Cookie` can never be folded into one field line, the list-valued headers a provider realistically sets (`Vary` above all) mean the union of their field lines, and the single-valued headers where replacing would be right are exactly the ones `reserved_response_effect` already fails the request for. So nothing a provider may set here needs to replace, and appending is the direction that cannot silently destroy someone else's header. No test anywhere covered a provider header reaching a response. The new one drives a provider that sets its own cookie and its own `Vary` through the real mint path onto a response the origin has already written to, and asserts the origin's cookie, the provider's cookie, core's own `ts-ec` and both `Vary` entries all survive. --- crates/trusted-server-core/src/ec/finalize.rs | 136 +++++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 37 +++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 908cb5959..337a02126 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -16,6 +16,7 @@ use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; +use super::provider::apply_provider_response_headers; use super::registry::PartnerRegistry; /// TS-managed response headers tied to EC identity output. @@ -56,10 +57,13 @@ pub fn ec_finalize_response( // one was checked against core's reserved response surface at capture // time in `EcContext::generate_with_provider`, so nothing here can set a // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop - // header. - for (name, value) in ec_context.response_headers() { - response.headers_mut().insert(name, value.clone()); - } + // header. They accumulate with whatever the origin returned rather than + // replacing it, for the reasons on + // `provider::apply_provider_response_headers`. + apply_provider_response_headers( + response.headers_mut(), + ec_context.response_headers().iter().cloned(), + ); let ec_permitted = ec_context.ec_allowed(); @@ -862,4 +866,128 @@ mod tests { "EID ingestion should not create a row under the raw cookie value" ); } + + /// A provider that sets one cookie of its own and one `Vary` entry, the + /// two response effects a provider realistically asks for, so a test can + /// watch both land on a response the origin already wrote headers to. + #[derive(Debug)] + struct EvidenceHeaderProvider; + + impl crate::ec::provider::EdgeCookieProvider for EvidenceHeaderProvider { + fn id(&self) -> &'static str { + "evidence-header" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::ec::provider::ProviderCode::new("t0eh") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie { + id: Some("evidence-id".to_owned()), + response_headers: vec![ + ( + http::header::SET_COOKIE, + HeaderValue::from_static("vendor-ev=abc; Path=/"), + ), + (http::header::VARY, HeaderValue::from_static("sec-ch-ua")), + ], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn provider_response_headers_reach_the_response_without_dropping_the_origins() { + // The response finalization runs on is the finished one, so it already + // carries the publisher origin's own headers. A provider effect must + // add to those, never replace them: replacing `Set-Cookie` would drop + // the publisher's session and sign-in cookies, and replacing `Vary` + // would break the caching the origin asked for. + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("finalize-provider-headers-store"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) + .with_provider_for_test(std::sync::Arc::new(EvidenceHeaderProvider)); + ec_context + .generate_if_needed(&settings, Some(&graph)) + .expect("should mint through the provider"); + + // What the publisher's origin returned, before EC finalization runs. + let mut response = empty_response(); + response.headers_mut().append( + http::header::SET_COOKIE, + HeaderValue::from_static("publisher_session=origin-value; Path=/; HttpOnly"), + ); + response.headers_mut().append( + http::header::VARY, + HeaderValue::from_static("accept-encoding"), + ); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("publisher_session=origin-value")), + "the origin's own cookie must survive a provider effect, got {cookies:?}" + ); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("vendor-ev=abc")), + "the provider's cookie must reach the response, got {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie must still be written, got {cookies:?}" + ); + + let vary: Vec<&str> = response + .headers() + .get_all(http::header::VARY) + .iter() + .map(|value| value.to_str().expect("should render vary as utf-8")) + .collect(); + assert!( + vary.contains(&"accept-encoding"), + "the origin's Vary must survive a provider effect, got {vary:?}" + ); + assert!( + vary.contains(&"sec-ch-ua"), + "the provider's Vary must reach the response, got {vary:?}" + ); + } } diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 0503995ed..606efe4fc 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -266,6 +266,43 @@ pub fn reserved_response_effect( None } +/// Applies a provider's response headers to a response that already carries +/// the publisher origin's own. +/// +/// Every header here accumulates with what the origin returned rather than +/// replacing it, because a provider on this seam only ever adds evidence about +/// the request. It is never correcting the origin's output, so core has no +/// grounds to discard a value it did not write. Working through the headers a +/// provider can actually set: +/// +/// - `Set-Cookie` can never be folded into one field line, so replacing it +/// drops every cookie the origin set, a publisher's session and sign-in +/// cookies included. This is the case the whole rule turns on, because +/// `response_headers` is a list of pairs precisely so a provider can set more +/// than one cookie of its own, and replacing collapses those too. +/// - The list-valued headers a provider realistically sets, `Vary` first among +/// them, mean the union of their field lines. Replacing the origin's +/// `Vary: Accept-Encoding` with the provider's own would break the cache +/// correctness the origin asked for. +/// - The single-valued headers where replacing would be the right answer are +/// exactly the ones a provider must not author at all, and +/// [`reserved_response_effect`] already fails the request for them: core's +/// `x-ts-` namespace, the `ts-` managed cookies, and the framing and +/// hop-by-hop set. +/// +/// So nothing a provider is permitted to set here needs to replace, and +/// accumulating is the direction that cannot silently destroy someone else's +/// header. Appending where one value was wanted leaves a duplicate a reviewer +/// can see; replacing where two were wanted leaves nothing at all. +pub(crate) fn apply_provider_response_headers(headers: &mut http::HeaderMap, provider_headers: I) +where + I: IntoIterator, +{ + for (name, value) in provider_headers { + headers.append(name, value); + } +} + /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// From 67c3b0b7fe98e458af237194fcbe21470497414b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 13:23:50 +0100 Subject: [PATCH 024/133] State what a provider switch really does to existing identities The pluggable-providers spec required, in its provider-switching section, that switching must not strand the identities the previous provider minted and "above all must not make a later opt-out unable to revoke them". It then claimed old cookies stay recognized after a switch whenever the newly selected provider accepts their shape. That claim is false and cannot be made true here. Ownership is decided on the `{code}~` prefix before any provider is asked about shape, and the check is enforced twice: `AcceptedProviders::owner` dispatches on the code, and `canonical_kv_key` re-checks the derived key through `provider_owns_id`. So a newly selected provider rejects every identifier the previous one minted, whatever its shape. The new test drives this and shows the result: after a switch the retired identifier is never adopted, withdrawal still expires the browser cookie, but the retired provider's identity-graph row keeps `consent.ok = true` and is never tombstoned. It then sits for the one-year entry TTL. I changed the spec rather than the code. The mechanism the spec itself names for carrying identities across a switch is the `legacy_providers` reader list, which the same section marks as deferred to the migration spec, and `AcceptedProviders` is already built as the seam for it. Even once it lands the requirement would not hold on its own, because it would depend on the operator listing the retired provider, so an unconditional guarantee was never something the code could provide. The old wording also contradicted section 5 of the same document, which already states the true rule that a cookie carrying another provider's code is treated as absent. The replacement says plainly what a switch does to read-back, to the browser cookie and to the graph rows, and what an operator must do about revocation: deal with the retired provider's rows at the switch, since they are identifiable by that provider's `{code}~` key prefix, or accept that later withdrawals are recorded only in the browser until the TTL expires. The `cookie_ec_kv_key` doc comment claimed the same reach the spec did and is corrected to match. --- crates/trusted-server-core/src/ec/finalize.rs | 124 ++++++++++++++++++ crates/trusted-server-core/src/ec/mod.rs | 16 ++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 337a02126..49e3861b9 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -990,4 +990,128 @@ mod tests { "the provider's Vary must reach the response, got {vary:?}" ); } + + /// A provider standing in for the one a deployment switched *to*, with a + /// different registered code from the provider that minted the live row. + #[derive(Debug)] + struct SwitchedProvider; + + impl crate::ec::provider::EdgeCookieProvider for SwitchedProvider { + fn id(&self) -> &'static str { + "switched" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::ec::provider::ProviderCode::new("t0sw") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn switching_provider_leaves_the_previous_providers_row_beyond_withdrawal() { + // Pins what a provider switch really does, which the switching + // section of the pluggable-providers spec now states plainly. The + // retired provider's identifier is owned by nobody this deployment + // reads, so a later withdrawal expires the browser cookie but cannot + // tombstone the row, and the identifier is never adopted either. If + // the deferred `legacy_providers` reader list ever lands, this test + // is meant to fail, so that the spec sentence a deployer acts on is + // revisited in the same change. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + // A TCF record consenting to nothing, under GDPR, so the request + // carries an explicit refusal of storage. That is the narrow, + // destructive kind of withdrawal, the one that tombstones rather than + // merely suppressing, which is the behavior under test. + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: ConsentSource::Cookie, + ..Default::default() + }; + // The browser still carries the identifier the previous provider + // minted, but the deployment now runs a provider with a different + // code, so read-back treats the cookie as absent and the active + // identifier is empty. + let ec_context = make_context_with_consent( + None, + Some(CANONICAL_COOKIE_VALUE), + false, + false, + consent, + false, + ) + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + assert!( + ec_context.ec_value().is_none(), + "the retired provider's identifier must never be adopted by the new one" + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the previous provider's row") + .expect("the previous provider's row should still exist"); + assert!( + row.consent.ok, + "withdrawal cannot reach a retired provider's row without the provider that owns the code" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && cookie.contains("Max-Age=0")), + "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 90dfc06a4..22f559b38 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -505,9 +505,19 @@ impl EcContext { /// The identity-graph key for the `ts-ec` cookie the request carried. /// /// Withdrawal tombstones the cookie's row as well as the active one, - /// because a stateless deployment and a cookie the active provider no - /// longer mints both leave [`ec_kv_key`](Self::ec_kv_key) empty while a - /// live row still exists. + /// because a stateless deployment leaves [`ec_kv_key`](Self::ec_kv_key) + /// empty while a live row still exists, and the cookie is the only way + /// back to it. + /// + /// This does not reach across a provider switch. An identifier minted + /// under a retired provider's `{code}~` prefix is owned by no provider + /// this deployment reads, so [`kv_key_for`](Self::kv_key_for) yields + /// `None` and its row is never tombstoned. Core cannot derive that key, + /// because the canonical form is the owning provider's own normalization. + /// The browser cookie is still expired, since that path keys off the raw + /// cookie rather than off ownership. See the switching section of + /// `docs/superpowers/specs/2026-07-30-pluggable-providers-design.md` for + /// what an operator has to do about it. #[must_use] pub(crate) fn cookie_ec_kv_key(&self) -> Option { self.existing_cookie_ec_id() From 1f62e1a7a62fa9be503ee7f1837ab5458796f395 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 08:37:09 +0100 Subject: [PATCH 025/133] Name the design documents rather than their paths in doc comments The provider series design specs move to the spec-only PR (#1084) so they can be reviewed before the code that implements them. Three doc comments cited those files by repository path, which no longer resolves from this branch. Refer to each document by name instead, so the comment stays true whichever PR is read first. --- crates/trusted-server-core/src/ec/mod.rs | 6 +++--- crates/trusted-server-core/src/ec/provider.rs | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 22f559b38..b09e90e37 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -515,9 +515,9 @@ impl EcContext { /// `None` and its row is never tombstoned. Core cannot derive that key, /// because the canonical form is the owning provider's own normalization. /// The browser cookie is still expired, since that path keys off the raw - /// cookie rather than off ownership. See the switching section of - /// `docs/superpowers/specs/2026-07-30-pluggable-providers-design.md` for - /// what an operator has to do about it. + /// cookie rather than off ownership. See the provider-switching section + /// of the pluggable providers design spec for what an operator has to do + /// about it. #[must_use] pub(crate) fn cookie_ec_kv_key(&self) -> Option { self.existing_cookie_ec_id() diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 606efe4fc..b9d02d584 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -306,12 +306,12 @@ where /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// -/// Exactly four characters from `[a-z0-9]`, allocated append-only in -/// `docs/superpowers/specs/provider-code-registry.md` and never reused. The -/// code appears as the `{code}~` prefix of every identifier the provider -/// mints, so identifiers from different providers can never collide in the -/// cookie, the identity graph, or a withdrawal, and each identifier records -/// which provider created it. +/// Exactly four characters from `[a-z0-9]`, allocated append-only in the +/// provider-code registry and never reused. The code appears as the +/// `{code}~` prefix of every identifier the provider mints, so identifiers +/// from different providers can never collide in the cookie, the identity +/// graph, or a withdrawal, and each identifier records which provider +/// created it. #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] pub struct ProviderCode(&'static str); @@ -540,10 +540,10 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// identifier it mints. /// /// Mandatory, with no default: a provider must allocate a unique code in - /// `docs/superpowers/specs/provider-code-registry.md` before it can exist, - /// so no two providers can ever mint colliding identifiers. Core applies - /// the code at mint and checks it at read-back, and the provider itself - /// only ever sees its own value part. + /// the provider-code registry before it can exist, so no two providers + /// can ever mint colliding identifiers. Core applies the code at mint and + /// checks it at read-back, and the provider itself only ever sees its own + /// value part. fn code(&self) -> ProviderCode; /// Derives an Edge Cookie identifier from the provider's injected services From c7464bc4a109ed3cd4f3a22573175be9bc99a48a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 13:00:35 +0100 Subject: [PATCH 026/133] Restore the line continuations missed in the neighbouring files The mint-rejection fix restored one collapsed continuation in ec/mod.rs and said the rest of that file was clean, which it was. The same fault exists in four more places on this branch, so fixing only the reported one leaves the pattern half addressed. Each was written across two source lines without the trailing backslash, so the source indentation became a run of spaces inside the message: ec/admin.rs:373 the invalid-EC-ID response an operator sees ec/finalize.rs:125 the skipped-response-write log line ec/provider.rs:635 the missing-client-IP error from the HMAC provider ec/pull_sync.rs:72 the skipped-dispatch log line integrations/testlight.rs:196 the no-recognized-EC-ID error The continuation is restored in each, so every message reads as one sentence. The whole of trusted-server-core was scanned for the same shape, matching runs of five or more spaces inside a string literal. The only remaining matches are TOML fixtures in settings.rs tests, where the embedded newlines are deliberate. Addresses: ec/mod.rs:444 follow-up, the same fault outside the file first reported --- crates/trusted-server-core/src/ec/admin.rs | 4 +++- crates/trusted-server-core/src/ec/finalize.rs | 3 ++- crates/trusted-server-core/src/ec/provider.rs | 3 ++- crates/trusted-server-core/src/ec/pull_sync.rs | 3 ++- crates/trusted-server-core/src/integrations/testlight.rs | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index bb15b6892..56be6f44e 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -370,7 +370,9 @@ fn requested_ec_id( if !accepted_providers.accepts(&ec_id) { return Err(Box::new(json_error( StatusCode::BAD_REQUEST, - "invalid EC ID: not an identifier any provider this deployment reads issued (the built-in HMAC provider issues {64hex}.{6alnum}, with or without the hmac~ prefix)", + "invalid EC ID: not an identifier any provider this deployment reads \ + issued (the built-in HMAC provider issues {64hex}.{6alnum}, with or \ + without the hmac~ prefix)", ))); } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 49e3861b9..33167d1a7 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -122,7 +122,8 @@ pub fn ec_finalize_response( if ec_context.ec_generated() { let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) else { log::info!( - "Skipping generated EC response write because the KV graph or the identity-graph key is unavailable" + "Skipping generated EC response write because the KV graph or the \ + identity-graph key is unavailable" ); return; }; diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b9d02d584..27898b661 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -632,7 +632,8 @@ impl EdgeCookieProvider for HmacProvider { let client_ip = request_info.client_ip(); if client_ip.is_empty() { return Err(Report::new(TrustedServerError::EdgeCookie { - message: "Edge Cookie provider `hmac` requires the client IP, and this host could not supply one" + message: "Edge Cookie provider `hmac` requires the client IP, and this host \ + could not supply one" .to_owned(), })); } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index e55baeb40..3b5f43152 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -69,7 +69,8 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option Date: Sun, 30 Aug 2026 13:00:58 +0100 Subject: [PATCH 027/133] Correct the two provider doc comments the earlier pass missed The commit that rewrote the module docs to match the trait signature left two item-level doc comments in the same file still describing constructor injection, so the claim that nothing passes evidence by constructor was contradicted three declarations further down. IdentityInput's doc said request data reaches a provider "through the services injected into its constructor". EdgeCookieProvider::generate's doc said the identifier is derived "from the provider's injected services". Neither matches the signature, which takes request_info: &dyn RequestInfo as a parameter and reads evidence from it. The built-in HMAC provider does exactly that at ec/provider.rs:632. Both now describe the parameter the evidence actually arrives on. The crate was searched for the same wording; the only other mention is in ec/mod.rs on a test-only helper, where it correctly describes how the provider itself is constructed rather than how request evidence reaches it. Addresses: ec/provider.rs:4 follow-up, item docs still describing constructor injection --- crates/trusted-server-core/src/ec/provider.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 27898b661..72d94e30c 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -128,10 +128,11 @@ pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// -/// Request data (client IP, User-Agent, headers, host signals) reaches a -/// provider through the services injected into its constructor, not through this -/// struct. This carries only the per-request gating context a provider may read -/// for behavior beyond gating. The gate has already confirmed Edge Cookie +/// Request data reaches a provider through the `request_info` parameter of +/// [`EdgeCookieProvider::generate`], not through this struct and not through +/// anything injected into the provider's constructor. This struct carries only +/// the per-request gating context a provider may read for behavior beyond +/// gating. The gate has already confirmed Edge Cookie /// storage is allowed before `generate` is called. #[derive(Default)] pub struct IdentityInput<'a> { @@ -546,8 +547,8 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// value part. fn code(&self) -> ProviderCode; - /// Derives an Edge Cookie identifier from the provider's injected services - /// and the request's gating context. + /// Derives an Edge Cookie identifier from the request evidence in + /// `request_info` and the gating context in `input`. /// /// # Errors /// From acb416f0b415adf7365159be500779bb7fa9343f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:03:42 +0100 Subject: [PATCH 028/133] Stop a provider code from panicking when a vendor builds one at run time `ProviderCode::new` is public and validated its argument with `assert!`, so any caller outside this workspace could take down a live request by passing a code that was not exactly four characters of [a-z0-9]. The doc comment claimed the panic "never" fires on a request path, which held only for as long as every caller happened to pass a literal, and nothing enforced that. A vendor Edge Cookie provider is exactly the caller the claim could not cover. `new` now returns `Option`, so it cannot panic whatever it is given, and a caller outside core has to handle a malformed code. The compile-time guarantee the codes in this workspace relied on moves into a new `provider_code!` macro, which runs the same check inside a `const` block, so a bad literal fails the build and the value it yields needs no unwrapping. Every code in the workspace, the built-in HMAC code included, now goes through the macro. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `ProviderCode::new` could panic at run time while its documentation said it could not. --- crates/trusted-server-core/src/ec/admin.rs | 2 +- .../trusted-server-core/src/ec/batch_sync.rs | 2 +- crates/trusted-server-core/src/ec/finalize.rs | 4 +- crates/trusted-server-core/src/ec/mod.rs | 12 +- crates/trusted-server-core/src/ec/provider.rs | 114 +++++++++++++++--- .../trusted-server-core/src/ec/pull_sync.rs | 2 +- 6 files changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 56be6f44e..2fb7c4b5b 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -1543,7 +1543,7 @@ mod tests { } fn code(&self) -> super::super::provider::ProviderCode { - super::super::provider::ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index e75c02ddb..046cd1da6 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -301,7 +301,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 33167d1a7..12fdc3a25 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -880,7 +880,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0eh") + crate::provider_code!("t0eh") } fn generate( @@ -1003,7 +1003,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0sw") + crate::provider_code!("t0sw") } fn generate( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index b09e90e37..e064ad23d 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -781,7 +781,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( @@ -878,7 +878,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0so") + crate::provider_code!("t0so") } fn generate( @@ -957,7 +957,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ni") + crate::provider_code!("t0ni") } fn generate( @@ -1065,7 +1065,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0il") + crate::provider_code!("t0il") } fn generate( @@ -1126,7 +1126,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0hs") + crate::provider_code!("t0hs") } fn generate( @@ -1276,7 +1276,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ca") + crate::provider_code!("t0ca") } fn generate( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 72d94e30c..67e880a7a 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -124,7 +124,7 @@ pub const HMAC_PROVIDER_KEY: &str = "hmac"; /// `{code}~` namespace stamped on every identifier the built-in provider /// mints, and it is what [`generation`] matches when it decides whether an /// enveloped identifier is one of its own. -pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY); +pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER_KEY); /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// @@ -317,30 +317,41 @@ where pub struct ProviderCode(&'static str); impl ProviderCode { - /// Creates a provider code, validating the registry format. + /// Creates a provider code when `code` matches the registry format. /// - /// # Panics + /// Returns `None` when `code` is not exactly four characters of `[a-z0-9]`, + /// so a caller that assembles a code from anything other than a literal is + /// handed an answer it has to deal with rather than a panic. Nothing in + /// this function can panic, whatever it is called with and wherever it is + /// called from. /// - /// Panics when `code` is not exactly four characters of `[a-z0-9]`. Codes - /// are compile-time literals, so the panic fires in tests and never on a - /// request path. + /// Use [`provider_code!`](crate::provider_code) for a literal. That macro + /// runs this check while the crate is compiled, so a malformed code is a + /// build failure and the resulting value needs no unwrapping. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::ec::provider::ProviderCode; + /// + /// assert_eq!(ProviderCode::new("t0ac").map(ProviderCode::as_str), Some("t0ac")); + /// assert_eq!(ProviderCode::new("nope!"), None); + /// ``` #[must_use] - pub const fn new(code: &'static str) -> Self { + pub const fn new(code: &'static str) -> Option { let bytes = code.as_bytes(); - assert!( - bytes.len() == 4, - "provider code must be exactly four characters" - ); + if bytes.len() != 4 { + return None; + } let mut i = 0; while i < bytes.len() { let b = bytes[i]; - assert!( - b.is_ascii_lowercase() || b.is_ascii_digit(), - "provider code characters must be [a-z0-9]" - ); + if !b.is_ascii_lowercase() && !b.is_ascii_digit() { + return None; + } i += 1; } - Self(code) + Some(Self(code)) } /// The code as a string slice. @@ -350,6 +361,34 @@ impl ProviderCode { } } +/// Builds a [`ProviderCode`] from a constant, checked while the crate is +/// compiled. +/// +/// The check runs inside a `const` block, so a code that is not exactly four +/// characters of `[a-z0-9]` fails the build instead of panicking at run time, +/// and the value the macro produces needs no unwrapping. Every provider code in +/// this workspace is written through this macro, which is what makes +/// [`ProviderCode::new`]'s fallible form safe to hand to anyone else. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::provider_code; +/// +/// assert_eq!(provider_code!("t0ac").as_str(), "t0ac"); +/// ``` +#[macro_export] +macro_rules! provider_code { + ($code:expr) => { + const { + match $crate::ec::provider::ProviderCode::new($code) { + Some(code) => code, + None => panic!("provider code must be exactly four characters of [a-z0-9]"), + } + } + }; +} + /// The separator between a provider code and the provider's identifier value. /// /// The tilde is inside the cookie-safe identifier alphabet and outside the @@ -797,6 +836,45 @@ mod tests { use super::*; use crate::settings::{EcProviders, HmacProviderConfig}; + #[test] + fn a_malformed_provider_code_is_refused_rather_than_panicking() { + // `ProviderCode::new` is public, so a vendor crate can reach it with a + // value it assembled rather than a literal. Every rejected shape has to + // come back as `None`, because a panic here would take down whatever + // request the caller was serving. + for malformed in ["", "abc", "abcde", "AB12", "t0a_", "t0a-", "t0a ", "t.ac"] { + assert_eq!( + ProviderCode::new(malformed), + None, + "`{malformed}` is outside the registry format and should be refused" + ); + } + + assert_eq!( + ProviderCode::new("t0ac").map(ProviderCode::as_str), + Some("t0ac"), + "a well-formed code should still be accepted" + ); + } + + #[test] + fn the_provider_code_macro_keeps_the_compile_time_guarantee() { + // The macro checks a literal while the crate is compiled and yields the + // code itself, so the codes written across this workspace stay as + // strong as the old panicking constructor made them, with none of the + // run-time risk. + assert_eq!( + crate::provider_code!("t0ac").as_str(), + "t0ac", + "the macro should yield the code it was given" + ); + assert_eq!( + HMAC_PROVIDER_CODE.as_str(), + HMAC_PROVIDER_KEY, + "the built-in code should still be the built-in key" + ); + } + #[test] fn split_provider_code_separates_coded_and_legacy_forms() { assert_eq!( @@ -919,7 +997,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ac") + crate::provider_code!("t0ac") } fn generate( @@ -1162,7 +1240,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0in") + crate::provider_code!("t0in") } fn generate( diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 3b5f43152..0cf8cf214 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -518,7 +518,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( From 8eb0a9bcf05bbe6a80a893db184c0d6a96354007 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:09:42 +0100 Subject: [PATCH 029/133] Refuse two Edge Cookie providers claiming the same name `resolve_named_provider` looked for a built-in provider before the one the adapter injects, so a vendor provider whose id is `hmac` was dropped in favour of core's own and nothing said so. Nothing reserved the name and nothing warned, which left an operator with a configured vendor provider that never ran and no way to see why. This is not only a missing warning. Once this work merges, IAB Tech Lab is itself a vendor shipping an HMAC provider while core still ships one, so two suppliers really can arrive under one name in a single deployment, and there is no correct way to pick between them. `build_provider` now refuses that pair through `ensure_no_name_collision` and the error names both claimants, core and the deployment's adapter, along with the contested name. The check runs before the selector is read, so selecting a different provider does not hide the clash, and because the adapters call it through `ensure_provider_available` while they build application state, an operator is told at startup rather than on the first request that happens to select the name. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `resolve_named_provider` silently preferred the built-in `hmac` provider over an injected one of the same name. --- crates/trusted-server-core/src/ec/provider.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 67e880a7a..af22a9a51 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -118,6 +118,16 @@ impl From for String { /// built-in provider becomes a module of its own. pub const HMAC_PROVIDER_KEY: &str = "hmac"; +/// The provider names core supplies itself. +/// +/// A name in this list is already taken, so an adapter that injects a provider +/// under one of them has two suppliers claiming a single name and +/// [`build_provider`] refuses the pair rather than picking one. The list grows +/// and shrinks with the resolution arms in [`resolve_named_provider`], and it +/// empties when the built-in HMAC provider becomes a module like every other +/// provider, at which point no name is reserved and every provider is injected. +const BUILTIN_PROVIDER_KEYS: &[&str] = &[HMAC_PROVIDER_KEY]; + /// The registry code of the built-in HMAC provider. /// /// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the @@ -685,6 +695,47 @@ impl EdgeCookieProvider for HmacProvider { } } +/// Refuses an injected provider that claims a name core supplies itself. +/// +/// Two suppliers cannot own one name. Core ships the `hmac` provider, and once +/// this work merges IAB Tech Lab is itself a vendor shipping an HMAC provider, +/// so the two really can arrive under the same name in one deployment. The +/// resolution order alone would answer that by quietly preferring the built-in +/// one and dropping the injected provider, which an operator has no way to see, +/// so the pair is refused here and the error names both claimants. +/// +/// The check runs whatever the selector says, so an operator is told at startup +/// rather than on the first request that happens to select the contested name, +/// and it runs before the selection is read so a deployment cannot hide the +/// clash by selecting something else. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the injected provider's id +/// is one of [`BUILTIN_PROVIDER_KEYS`]. +fn ensure_no_name_collision( + injected: Option<&dyn EdgeCookieProvider>, +) -> Result<(), Report> { + let Some(injected) = injected else { + return Ok(()); + }; + let Some(claimed) = BUILTIN_PROVIDER_KEYS + .iter() + .find(|key| **key == injected.id()) + else { + return Ok(()); + }; + Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider name `{claimed}` is claimed twice, by the provider \ + built into Trusted Server core and by the provider this deployment's \ + adapter injects. Give the injected provider a name of its own and select \ + it under that name, because `[ec] provider = \"{claimed}\"` cannot mean \ + both of them." + ), + })) +} + /// Builds the Edge Cookie provider named by the `[ec] provider` selector, /// injecting the services it needs. /// @@ -704,6 +755,7 @@ pub fn build_provider( ec: &Ec, injected: Option>, ) -> Result>, Report> { + ensure_no_name_collision(injected.as_deref())?; let Some(selection) = ec.provider.as_ref() else { return Ok(None); }; @@ -727,6 +779,10 @@ pub fn build_provider( /// provider is used when its own id matches the name, and its /// `[ec.providers.]` block is read by the adapter that built it. /// +/// Looking at core first is safe only because +/// [`ensure_no_name_collision`] has already refused an injected provider that +/// claims a built-in name, so this order can never shadow one silently. +/// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when the name matches no provider @@ -1278,6 +1334,82 @@ mod tests { ); } + /// A vendor provider that claims the name core already uses for its + /// built-in HMAC provider. + #[derive(Debug)] + struct VendorNamedHmacProvider; + + impl EdgeCookieProvider for VendorNamedHmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0vh") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn two_providers_claiming_one_name_are_refused_and_both_are_named() { + // Once this work merges, IAB Tech Lab supplies an HMAC provider as a + // vendor module while core still supplies one of its own, so a + // deployment really can wire two providers called `hmac`. Resolution + // order alone would prefer the built-in one and drop the injected one + // with nothing said, which is the fault this guards. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let selected_hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = build_provider(&selected_hmac, Some(Arc::new(VendorNamedHmacProvider))) + .expect_err("two providers claiming `hmac` should be refused"); + let message = err.to_string(); + assert!( + message.contains(HMAC_PROVIDER_KEY), + "the error should name the contested name, got: {message}" + ); + assert!( + message.contains("core") && message.contains("adapter"), + "the error should name both claimants, got: {message}" + ); + + // The clash is a wiring fault, not a property of the selection, so + // selecting something else does not hide it and the operator still + // learns at startup. + let selected_elsewhere = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + let err = + ensure_provider_available(&selected_elsewhere, Some(Arc::new(VendorNamedHmacProvider))) + .expect_err("the clash should be refused whatever the selector says"); + assert!( + err.to_string().contains(HMAC_PROVIDER_KEY), + "the startup check should name the contested name too, got: {err}" + ); + + // A vendor name of its own is unaffected. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + build_provider(&vendor, Some(Arc::new(VendorProvider))) + .expect("a vendor provider under its own name should still build"); + } + #[test] fn a_selected_but_uninjected_vendor_provider_fails_loudly() { let ec = Ec { From f2b182539e03e5b487764006b16b980b56156829 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:13:49 +0100 Subject: [PATCH 030/133] Build the internal header list from the Edge Cookie response headers `EC_RESPONSE_HEADERS` in the EC finalization module and the first four entries of `INTERNAL_HEADERS` in the constants module were the same four header names written out twice, in two files, with nothing keeping them in step. The two lists do different jobs, one is stripped from a response the request may not carry an identity on and the other is never forwarded to a third party, but every Edge Cookie output header has to be in both, so adding a fifth to one and forgetting the other would send Edge Cookie output to an origin that should never see it. `EC_RESPONSE_HEADERS` now lives once, in the constants module, and `INTERNAL_HEADERS` is assembled from it and the remaining internal names while the crate is compiled, so the Edge Cookie half cannot be edited in one place and missed in the other. EC finalization reads the same constant instead of keeping a copy. The new test in the constants module asserts the containment, the total, and that no name appears twice, so going back to two hand-written lists fails the build. Addresses: crates/trusted-server-core/src/ec/finalize.rs and crates/trusted-server-core/src/constants.rs, where one list of Edge Cookie response headers was maintained by hand in two places. --- crates/trusted-server-core/src/constants.rs | 97 +++++++++++++++++-- crates/trusted-server-core/src/ec/finalize.rs | 9 +- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..2e7df513b 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -42,19 +42,31 @@ pub const HEADER_ACCEPT_LANGUAGE: HeaderName = HeaderName::from_static("accept-l pub const HEADER_ACCEPT_ENCODING: HeaderName = HeaderName::from_static("accept-encoding"); pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer"); -/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// The fixed response headers that carry Edge Cookie identity output. /// -/// These headers are used internally by Trusted Server for identification, geo-enrichment, -/// debugging, and compression hints. Leaking them to external origins could expose -/// data and internal implementation details. +/// EC finalization strips these from a response the request was not permitted +/// to carry an identity on (see `clear_ec_headers_on_response` in +/// [`finalize`](crate::ec::finalize)), and they are also internal headers, so +/// [`INTERNAL_HEADERS`] is built from this list rather than repeating it. That +/// is the whole reason the list lives here alongside `INTERNAL_HEADERS` and not +/// beside its only reader, because two hand-written copies of one list drift as +/// soon as a header is added to one of them. /// -/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear -/// in `const` context. -pub const INTERNAL_HEADERS: &[&str] = &[ +/// Uses `&str` slices for the same reason [`INTERNAL_HEADERS`] does. +pub const EC_RESPONSE_HEADERS: &[&str] = &[ "x-ts-ec", "x-ts-eids", "x-ts-ec-consent", "x-ts-eids-truncated", +]; + +/// The internal headers that are not part of the Edge Cookie output surface. +/// +/// Kept apart from [`EC_RESPONSE_HEADERS`] only so [`INTERNAL_HEADERS`] can be +/// assembled from the two without repeating either. Add a header here unless it +/// is one EC finalization has to strip, in which case it belongs in +/// [`EC_RESPONSE_HEADERS`] and reaches [`INTERNAL_HEADERS`] from there. +const NON_EC_INTERNAL_HEADERS: &[&str] = &[ "x-pub-user-id", "x-subject-id", "x-consent-advertising", @@ -76,6 +88,43 @@ pub const INTERNAL_HEADERS: &[&str] = &[ "x-ts-tls-cipher", ]; +/// How many names [`INTERNAL_HEADERS`] holds. +const INTERNAL_HEADER_COUNT: usize = EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(); + +/// Joins the two source lists into the array [`INTERNAL_HEADERS`] borrows. +/// +/// Written as a `const fn` because slice concatenation is not available in a +/// `const` initializer, and the join has to happen while the crate is compiled +/// so no caller pays for it. +const fn join_internal_headers() -> [&'static str; INTERNAL_HEADER_COUNT] { + let mut joined = [""; INTERNAL_HEADER_COUNT]; + let mut i = 0; + while i < EC_RESPONSE_HEADERS.len() { + joined[i] = EC_RESPONSE_HEADERS[i]; + i += 1; + } + let mut j = 0; + while j < NON_EC_INTERNAL_HEADERS.len() { + joined[i + j] = NON_EC_INTERNAL_HEADERS[j]; + j += 1; + } + joined +} + +/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// +/// These headers are used internally by Trusted Server for identification, geo-enrichment, +/// debugging, and compression hints. Leaking them to external origins could expose +/// data and internal implementation details. +/// +/// Built at compile time from [`EC_RESPONSE_HEADERS`] followed by +/// [`NON_EC_INTERNAL_HEADERS`], so an Edge Cookie response header cannot be +/// added to one list and missed in the other. +/// +/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear +/// in `const` context. +pub const INTERNAL_HEADERS: &[&str] = &join_internal_headers(); + // Consent-related cookie names pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; pub const COOKIE_GPP: &str = "__gpp"; @@ -84,3 +133,37 @@ pub const COOKIE_US_PRIVACY: &str = "us_privacy"; // Consent-related header names pub const HEADER_SEC_GPC: HeaderName = HeaderName::from_static("sec-gpc"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_edge_cookie_response_header_is_an_internal_header() { + // These two lists used to be written out by hand in two files, with + // nothing keeping them in step, so a new Edge Cookie response header + // could be stripped by EC finalization and still forwarded to a third + // party. `INTERNAL_HEADERS` is now assembled from + // `EC_RESPONSE_HEADERS`, and this is the assertion that fails if + // anyone goes back to writing them out separately. + for header in EC_RESPONSE_HEADERS { + assert!( + INTERNAL_HEADERS.contains(header), + "`{header}` carries Edge Cookie output, so it must never be forwarded" + ); + } + + assert_eq!( + INTERNAL_HEADERS.len(), + EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(), + "every internal header should come from exactly one of the two source lists" + ); + + for (index, header) in INTERNAL_HEADERS.iter().enumerate() { + assert!( + !INTERNAL_HEADERS[index + 1..].contains(header), + "`{header}` is listed twice, so the two source lists overlap" + ); + } + } +} diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 12fdc3a25..2d2c28895 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; +use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; @@ -19,14 +20,6 @@ use super::prebid_eids::ingest_eid_cookies; use super::provider::apply_provider_response_headers; use super::registry::PartnerRegistry; -/// TS-managed response headers tied to EC identity output. -const EC_RESPONSE_HEADERS: &[&str] = &[ - "x-ts-ec", - "x-ts-eids", - "x-ts-ec-consent", - "x-ts-eids-truncated", -]; - /// Finalizes EC response behavior for all routes. /// /// Applies the resolved consent gate, last-seen updates, cookie From 52e26924cc31f8ce33ce90765f8031358c37b747 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:47:43 +0100 Subject: [PATCH 031/133] Resolve the Edge Cookie provider once per request instead of twice The composition root resolved `[ec] provider` and threw the provider away, keeping only the knowledge that the selection could be satisfied, and then the request path resolved the same settings again to get a provider it could use. On the Fastly, Cloudflare and Spin adapters that is twice for every request, because those three run a fresh instance per request and rebuild application state each time, which was confirmed by reading `run_app` in the matching edgezero adapters. The composition root now keeps what it resolved, in `AppState`, and hands the same instance to every request through the new `RuntimeServices::resolved_ec_provider`. Core reads it through `request_provider`, which returns the threaded instance when there is one and otherwise resolves exactly as before, so an adapter that threads nothing, the core tests and any embedder driving core directly included, keeps today's behaviour, the loud failure on a selected but uninjected provider included. Nothing about which provider is chosen changes, only how many times the choosing happens. The Axum adapter is deliberately left checking rather than keeping, because it is a long-lived process whose application state is built once at start-up, so it has no second resolution to save. Addresses: crates/trusted-server-core/src/ec/provider.rs and the Fastly, Cloudflare and Spin adapters, where `ensure_provider_available` and `EcContext::read_from_request` each built the provider once per request. --- crates/trusted-server-adapter-axum/src/app.rs | 5 ++ .../src/app.rs | 42 ++++++--- .../trusted-server-adapter-fastly/src/app.rs | 52 ++++++++--- crates/trusted-server-adapter-spin/src/app.rs | 54 +++++++---- crates/trusted-server-core/src/ec/mod.rs | 50 +++++++++-- crates/trusted-server-core/src/ec/provider.rs | 89 ++++++++++++++++++- crates/trusted-server-core/src/edge_cookie.rs | 6 +- .../src/platform/test_support.rs | 25 ++++++ .../trusted-server-core/src/platform/types.rs | 56 ++++++++++++ 9 files changed, 328 insertions(+), 51 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index f3c1403ba..8def77aac 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -81,6 +81,11 @@ fn build_state_with_settings( // no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what // `EcContext` sees per request; pass the injected provider here as well // once this adapter supplies one. + // + // This adapter checks rather than keeps what the check resolved, unlike the + // Fastly, Cloudflare and Spin adapters, because it is a long-lived process + // whose application state is built once at start-up while theirs is rebuilt + // for every request. There is no second resolution per request here to save. ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 2c20d4470..4a6255b1d 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,7 +18,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::ensure_provider_available; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -57,6 +57,16 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -115,12 +125,13 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { - // Composition root: reject a provider selection this adapter can never - // supply, once, before any request is served. This adapter injects no Edge - // Cookie provider into `RuntimeServices`, so `None` is exactly what - // `EcContext` sees per request; pass the injected provider here as well - // once this adapter supplies one. - ensure_provider_available(&settings.ec, None)?; + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie provider, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + let ec_provider = build_shared_provider(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -128,6 +139,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -135,8 +147,11 @@ fn build_state_with_settings( // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, @@ -195,7 +210,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -393,7 +408,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -698,7 +713,10 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_per_request_services(&ctx); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index c64ebfe6e..9509db878 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -113,8 +113,8 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; -use trusted_server_core::ec::provider::build_provider; -use trusted_server_core::ec::provider::ensure_provider_available; +use trusted_server_core::ec::provider::request_provider; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -162,6 +162,16 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + pub(crate) ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -194,12 +204,13 @@ pub(crate) fn build_state_from_settings( ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); - // Composition root: reject a provider selection this adapter can never - // supply, once, before any request is served. This adapter injects no Edge - // Cookie provider into `RuntimeServices`, so `None` is exactly what - // `EcContext` sees per request; pass the injected provider here as well - // once this adapter supplies one. - ensure_provider_available(&settings.ec, None)?; + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie provider, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + let ec_provider = build_shared_provider(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -213,6 +224,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, })) } @@ -280,7 +292,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - RuntimeServices::builder() + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) @@ -293,8 +305,16 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(Arc::new(FastlyPlatformGeo)) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) - .client_info(client_info) - .build() + .client_info(client_info); + + // Hand every request the provider resolved at the composition root, so the + // request path reuses that instance instead of resolving `[ec] provider` + // again. Nothing is set for a deployment that selects no provider, which + // resolves to nothing either way. + match state.ec_provider.clone() { + Some(provider) => builder.resolved_ec_provider(provider).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -575,7 +595,7 @@ async fn execute_named( // deployment recognizes, so build it here rather than // assuming the built-in HMAC shape. The read-only // diagnostic builds no EC request state to borrow it from. - let provider = build_provider(&state.settings.ec, services.ec_provider())?; + let provider = request_provider(&state.settings.ec, &services)?; handle_admin_ec_lookup(kv.as_ref(), ®istry, provider.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), @@ -743,7 +763,7 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> // A partner echoes back an identifier the deployment's own provider // minted, so validation and KV normalization are dispatched through // that provider rather than the built-in HMAC grammar. - let provider = build_provider(&state.settings.ec, services.ec_provider())?; + let provider = request_provider(&state.settings.ec, services)?; handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); @@ -1555,6 +1575,11 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // Resolved the same way the composition root resolves it, so this + // router behaves like a served one. + let ec_provider = + trusted_server_core::ec::provider::build_shared_provider(&settings.ec, None) + .expect("should resolve the Edge Cookie provider selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1563,6 +1588,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 294150959..388fc26dc 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,7 +16,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::ensure_provider_available; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -50,6 +50,16 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -73,12 +83,13 @@ fn build_state() -> Result, Report> { fn build_state_with_settings( settings: Settings, ) -> Result, Report> { - // Composition root: reject a provider selection this adapter can never - // supply, once, before any request is served. This adapter injects no Edge - // Cookie provider into `RuntimeServices`, so `None` is exactly what - // `EcContext` sees per request; pass the injected provider here as well - // once this adapter supplies one. - ensure_provider_available(&settings.ec, None)?; + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie provider, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + let ec_provider = build_shared_provider(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -86,6 +97,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -513,6 +525,13 @@ impl TrustedServerApp { } } +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) +} + fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); @@ -522,7 +541,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -534,7 +553,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -567,7 +586,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -610,7 +629,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -651,7 +670,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -664,7 +683,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -677,7 +696,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -694,7 +713,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -710,7 +729,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -935,6 +954,9 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. let services = build_runtime_services(&ctx); let req = ctx.into_request(); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index e064ad23d..2309b5ea3 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -78,7 +78,7 @@ use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; -use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput, build_provider}; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -202,12 +202,14 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - // Build the selected provider once. It is used here to decide whether - // the incoming cookie value is a usable identifier. Building it needs - // no request data, so nothing is cloned from the request. - let ec_provider = services.ec_provider(); + // Take the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier, and again by + // generation, which reuses this one rather than asking for another. + // Resolving needs no request data, so an adapter that resolved the + // selection while it built application state hands the same instance + // back here and nothing is built a second time on this request. let selected_provider: Option> = - build_provider(&settings.ec, ec_provider.clone())?.map(Arc::from); + provider::request_provider(&settings.ec, services)?; // Read back an existing identifier only when the selected provider // accepts its shape, so an opaque vendor identifier (for example a signed @@ -812,6 +814,42 @@ mod tests { } } + #[test] + fn read_from_request_reuses_the_provider_the_composition_root_resolved() { + // Reading EC state runs on every request, and it used to resolve + // `[ec] provider` itself even though the composition root had just + // resolved the same settings, so the provider was built twice per + // request. An adapter now threads the resolved provider through + // `RuntimeServices`, and the context has to take that instance. + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + + let ec_config = settings.ec.clone(); + let resolved = crate::ec::provider::build_shared_provider( + &ec_config, + Some(Arc::new(OpaqueIdProvider)), + ) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let req = create_test_request(&[]); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let used = ec + .selected_provider + .as_ref() + .expect("the context should hold the selected provider"); + assert!( + Arc::ptr_eq(used, &resolved), + "reading EC state should reuse the provider resolved at startup rather \ + than building a second one for this request" + ); + } + #[test] fn read_from_request_round_trips_an_opaque_provider_identifier() { use crate::platform::test_support::noop_services_with_ec_provider; diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index af22a9a51..fb20f32a2 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -847,10 +847,55 @@ pub fn ensure_provider_available( ec: &Ec, injected: Option>, ) -> Result<(), Report> { - build_provider(ec, injected)?; + build_shared_provider(ec, injected)?; Ok(()) } +/// Resolves the selected provider into a shared handle the composition root can +/// keep and the request path can reuse. +/// +/// The same resolution as [`build_provider`], returned as an `Arc` rather than +/// a `Box` so one instance can be threaded into +/// [`RuntimeServices`](crate::platform::RuntimeServices) and read by every +/// request without being built again. An adapter that calls this while it +/// builds application state gets the startup check +/// [`ensure_provider_available`] performs and the provider itself for one piece +/// of work rather than two. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_shared_provider( + ec: &Ec, + injected: Option>, +) -> Result>, Report> { + Ok(build_provider(ec, injected)?.map(Arc::from)) +} + +/// The Edge Cookie provider to use for this request. +/// +/// Resolving `[ec] provider` reads no request data, so an adapter that resolved +/// it once while it built application state and threaded the result into +/// [`RuntimeServices::resolved_ec_provider`](crate::platform::RuntimeServices::resolved_ec_provider) +/// gets that same instance back here, and nothing is resolved or constructed +/// again on the request path. An adapter that threaded nothing resolves here +/// instead, from the same settings and the same injected provider, which is +/// what the core tests and any embedder driving core directly do. +/// +/// # Errors +/// +/// The same errors as [`build_provider`], and only when the adapter threaded +/// nothing, because a threaded provider has already been resolved successfully. +pub fn request_provider( + ec: &Ec, + services: &crate::platform::RuntimeServices, +) -> Result>, Report> { + if let Some(resolved) = services.resolved_ec_provider() { + return Ok(Some(resolved)); + } + build_shared_provider(ec, services.ec_provider()) +} + /// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that /// [`build_provider`] returns. /// @@ -1444,6 +1489,48 @@ mod tests { ); } + #[test] + fn the_request_path_reuses_the_provider_the_composition_root_resolved() { + // A composition root resolves the selection once while it builds + // application state, which is the same work `build_provider` does on a + // request, so doing both means doing it twice for every request. The + // resolved provider is threaded into `RuntimeServices`, and this is the + // assertion that the request path takes it rather than resolving again: + // the same allocation, not merely an equal one. + let ec = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let resolved = build_shared_provider(&ec, Some(Arc::new(VendorProvider))) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let for_request = request_provider(&ec, &services) + .expect("the request path should take the resolved provider") + .expect("the resolved provider should be there"); + + assert!( + Arc::ptr_eq(&resolved, &for_request), + "the request path should reuse the resolved provider, not build a second one" + ); + + // An adapter that threads nothing still resolves for itself, so core + // driven directly behaves exactly as it did before. + let unthreaded = + crate::platform::test_support::noop_services_with_ec_provider(Arc::new(VendorProvider)); + let built = request_provider(&ec, &unthreaded) + .expect("an unthreaded adapter should resolve on the request path") + .expect("the selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "resolving on the request path should still select the injected provider" + ); + } + #[test] fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { // A selection the adapter cannot supply is knowable without a request, diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 67ffc0aba..fd593216b 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -14,7 +14,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::ec::generation::normalize_ip; #[cfg(test)] use crate::ec::provider::IdentityInput; -use crate::ec::provider::{build_provider, provider_owns_id}; +use crate::ec::provider::{provider_owns_id, request_provider}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; @@ -51,7 +51,7 @@ pub fn generate_ec_id( log::trace!("Generating fresh EC ID from normalized client context"); - let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + let Some(provider) = request_provider(&settings.ec, services)? else { log::info!("No Edge Cookie provider configured; running statelessly"); return Ok(None); }; @@ -148,7 +148,7 @@ pub fn recognized_ec_id( return Ok(None); }; - let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + let Some(provider) = request_provider(&settings.ec, services)? else { log::debug!( "No Edge Cookie provider configured; withholding the request's EC ID from egress" ); diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index cdfd3b96d..67203cb6d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -714,6 +714,31 @@ pub(crate) fn noop_services_with_ec_provider_without_client_ip( noop_services_with_ec_provider_and_ip(ec_provider, None) } +/// Build a [`RuntimeServices`] carrying an Edge Cookie provider that a +/// composition root already resolved, the way a production adapter threads it. +/// +/// Use this to check that the request path reuses that instance rather than +/// resolving `[ec] provider` for itself. [`noop_services_with_ec_provider`] +/// is the other half of the pair, offering a vendor provider as an input to +/// the selector instead of the selector's answer. +pub(crate) fn noop_services_with_resolved_ec_provider( + resolved: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .resolved_ec_provider(resolved) + .build() +} + fn noop_services_with_ec_provider_and_ip( ec_provider: Arc, client_ip: Option, diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a6c535ba9..5cd9c295a 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -196,6 +196,13 @@ pub struct RuntimeServices { /// own crate and be injected, so core never names a vendor (the same /// pattern as [`geo`](Self::geo)). pub(crate) ec_provider: Option>, + /// The Edge Cookie provider this deployment already resolved from + /// `[ec] provider` while it built application state. + /// + /// `None` when the adapter resolved nothing here, in which case the request + /// path resolves the selection itself, which is what a deployment that + /// selects no provider and the core tests both do. + pub(crate) resolved_ec_provider: Option>, } impl RuntimeServices { @@ -294,6 +301,21 @@ impl RuntimeServices { self.ec_provider.clone() } + /// Returns the Edge Cookie provider the composition root already resolved, + /// when the adapter threaded one through. + /// + /// Resolving `[ec] provider` reads no request data, so the answer is the + /// same for every request and an adapter that resolves it once while it + /// builds application state can hand the result here instead of the + /// request path resolving the same settings again. `None` means nothing was + /// threaded, so the request path resolves for itself. Read this through + /// [`request_provider`](crate::ec::provider::request_provider) rather than + /// directly, so both answers are handled in one place. + #[must_use] + pub fn resolved_ec_provider(&self) -> Option> { + self.resolved_ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -314,6 +336,25 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the resolved Edge Cookie provider + /// replaced. + /// + /// Adapters that build their per-request services through a shared helper + /// with no application state in hand use this to thread the provider the + /// composition root resolved. `None` leaves the request path to resolve + /// `[ec] provider` for itself, which is what a deployment selecting no + /// provider does. + #[must_use] + pub fn with_resolved_ec_provider( + self, + resolved_ec_provider: Option>, + ) -> Self { + Self { + resolved_ec_provider, + ..self + } + } + /// Returns a clone of this instance with the template cache replaced. /// /// Spike-only (#1009). @@ -362,6 +403,7 @@ pub struct RuntimeServicesBuilder { auction_telemetry_sink: Option>, client_info: Option, ec_provider: Option>, + resolved_ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -378,6 +420,7 @@ impl RuntimeServicesBuilder { auction_telemetry_sink: None, client_info: None, ec_provider: None, + resolved_ec_provider: None, } } @@ -469,6 +512,18 @@ impl RuntimeServicesBuilder { self } + /// Set the Edge Cookie provider the composition root already resolved. + /// + /// Optional, and different from [`ec_provider`](Self::ec_provider), which + /// is the vendor provider offered to the selector as an input. This one is + /// the output, the provider the selector actually chose, so setting it + /// keeps the request path from resolving the same settings a second time. + #[must_use] + pub fn resolved_ec_provider(mut self, provider: Arc) -> Self { + self.resolved_ec_provider = Some(provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -510,6 +565,7 @@ impl RuntimeServicesBuilder { .client_info .expect("should set client_info before building RuntimeServices"), ec_provider: self.ec_provider, + resolved_ec_provider: self.resolved_ec_provider, } } } From 1c7df606813028b6efd5cbae8cd1f2e1779bc19d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 20:49:55 +0100 Subject: [PATCH 032/133] Load Spin settings from the config store instead of a baked template The Spin adapter cannot start on upstream/main today, and this fixes it here. `build_state` compiled `trusted-server.example.toml` into the binary and parsed it, but that template ships placeholder secrets by design and its placeholder admin password is the first entry in `PASSWORD_PLACEHOLDERS`, so `validate_admin_handler_passwords` refused it every time. `build_state` therefore never returned `Ok`, the router fell back to the start-up error handler, and the component answered 503 to every request. The failure is "Handler `^/_ts/admin` uses a placeholder password; configure a strong secret". Nothing caught it because nothing called `build_state`. Every Spin test enters through the `routes_with_settings` parity seam and supplies its own settings, so the one path a deployed component actually takes was the one path never exercised. Settings now come from the platform config store at run time, which is what the Fastly, Axum and Cloudflare adapters already do, so an operator publishes one with `ts config push` and the component reads it. The new `SpinPlatformConfigStore` reads Spin component variables directly rather than through the per-request handle, because application state is built before any request context exists. Component variables are ambient, which is how the secret store already reads them, and both paths map keys through `spin_variable_name` so start-up and the request path read the same variable for the same key. The new test calls `build_state` and requires any failure to be the absence of a config store. Outside the Spin runtime there are no component variables, so it cannot return `Ok` under `cargo test`, but a configuration compiled into the binary would fail for a different reason and the test says so. Restoring the old body fails it with the placeholder-password message. Addresses: crates/trusted-server-adapter-spin/src/app.rs, where `build_state` parsed a baked example template whose placeholder admin password made every request fail. --- crates/trusted-server-adapter-spin/src/app.rs | 52 +++++++++++++++++-- .../src/platform.rs | 45 ++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 388fc26dc..d5faaa1c3 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -35,11 +35,14 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::{ + default_config_key, default_config_store_name, get_settings_from_config_store, +}; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; -use crate::platform::build_runtime_services; +use crate::platform::{SpinPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -64,12 +67,23 @@ pub struct AppState { /// Build the application state, loading settings and constructing all per-application components. /// +/// Settings are read from the platform config store at run time, the same way +/// the Fastly, Axum and Cloudflare adapters read them, so an operator publishes +/// one with `ts config push` and the deployed component picks it up. This +/// adapter previously compiled `trusted-server.example.toml` into the binary +/// and parsed it here, which could never succeed, because that template ships +/// placeholder secrets and the placeholder admin password fails validation. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialise. fn build_state() -> Result, Report> { - let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; + let store_name = default_config_store_name(); + let config_key = default_config_key(); + let settings = + get_settings_from_config_store(&SpinPlatformConfigStore, &store_name, &config_key)?; build_state_with_settings(settings) } @@ -912,6 +926,36 @@ mod tests { use super::*; + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // This adapter used to compile the shipped example template into the + // binary and parse it here. That template carries placeholder secrets + // by design, and the placeholder admin password fails + // `validate_admin_handler_passwords`, so `build_state` could never + // return `Ok` and the router fell back to the start-up error handler + // that answers every request with 503. Nothing caught it because every + // other test enters through the `routes_with_settings` parity seam and + // never calls this function. + // + // There is no Spin runtime under `cargo test`, so there are no + // component variables to read and this cannot return `Ok` here. What it + // must never do again is fail because of a configuration baked into the + // binary, so the failure has to be the absence of a config store and + // nothing else. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + /// Settings selecting a vendor Edge Cookie provider this adapter does not /// inject, with the `[ec.providers.]` block configuration validation /// requires. `acme` is a fictional vendor key. diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..6c6d1345d 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -149,6 +149,51 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } +/// Reads Trusted Server app config from Spin component variables, with no +/// request in hand. +/// +/// Application state is built before any request context exists, so the +/// per-request [`ConfigStoreHandleAdapter`] cannot serve it. Spin component +/// variables are ambient rather than request-scoped, which is how +/// `SpinSecretStoreAdapter` already reads secrets, so the same variables are +/// read directly here. Both paths map keys through [`spin_variable_name`], so +/// start-up and the request path read the same variable for the same key. +/// +/// Outside the Spin runtime, which includes every `cargo test` run on the host, +/// there are no component variables and every read reports that rather than +/// falling back to a configuration compiled into the binary. +pub struct SpinPlatformConfigStore; + +impl PlatformConfigStore for SpinPlatformConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + { + let variable_name = spin_variable_name(key, PlatformError::ConfigStore)?; + futures::executor::block_on(spin_sdk::variables::get(&variable_name)).map_err(|error| { + Report::new(PlatformError::ConfigStore).attach(format!( + "config store lookup failed for key `{key}` as Spin variable `{variable_name}`: {error}" + )) + }) + } + #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] + { + Err(Report::new(PlatformError::ConfigStore).attach(format!( + "no config store is available for key `{key}` outside the Spin runtime, where component variables cannot be read" + ))) + } + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } +} + fn spin_variable_name( key: &str, error_context: PlatformError, From 323409277a3bf42d30fc04f690a1d02602e17911 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 20:57:18 +0100 Subject: [PATCH 033/133] Stop exposing an inbound Edge Cookie identifier nothing has vouched for `get_ec_id` is public on upstream/main today and this fixes it here. It reads the `x-ts-ec` request header and then the `ts-ec` cookie, and checks the result only with `ec_id_has_only_allowed_chars`. That function is the global cookie backstop, the length cap and the cookie-safe alphabet, and its own documentation in `ec/cookies.rs` says the strict check is the one used to reject untrusted request values. On its own it accepts any run of `[A-Za-z0-9._~-]` up to the cap, so it cannot tell an identifier this deployment minted from one an attacker typed. `x-ts-ec` is stripped from responses but not from inbound requests, so the header really is the client's to set, and the raw reader prefers it over the cookie. This is the inbound twin of the egress fault this branch already fixes, which is why it belongs here. The right check is not the built-in strict format validator. A vendor provider's identifier is not required to match the HMAC `<64 hex>.<6 alphanumeric>` shape, so holding every deployment to it would drop exactly the opaque identifiers the provider model exists to carry. The right check is provider ownership, where the `{code}~` prefix is dispatched to the provider that owns it and that provider's `accepts_id` decides, which is what `recognized_ec_id` already does and what the EC lifecycle applies on read-back. The raw reader cannot make that check, because it has neither settings nor the selected provider, so it stops being a public entry point. It is now `pub(crate)` and named `unvalidated_ec_id_from_request`, so no caller can read it as returning a validated identifier, and `recognized_ec_id` is the only way in from outside the module. Nothing outside the crate called the old name. The new test drives three identifiers this deployment could never have issued through both readers, shows the bounds alone accept all three, and requires the public path to recognize none of them, while an identifier the selected provider does own is still returned. Replacing the ownership check with the bounds fails it on the first one. Addresses: crates/trusted-server-core/src/edge_cookie.rs, where `get_ec_id` was public and validated client-supplied identifiers with the outbound backstop list. --- crates/trusted-server-core/src/edge_cookie.rs | 116 +++++++++++++++--- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index fd593216b..3485e386e 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -73,18 +73,31 @@ pub fn generate_ec_id( Ok(generated.id) } -/// Gets an existing EC ID from the request. +/// Reads whatever the request offers as an Edge Cookie identifier, before any +/// check that this deployment could have issued it. /// -/// Attempts to retrieve an existing EC ID from: -/// 1. The `x-ts-ec` header -/// 2. The `ts-ec` cookie +/// Reads the `x-ts-ec` header first and then the `ts-ec` cookie. Both are +/// client-controlled. `x-ts-ec` is stripped from responses but is not stripped +/// from inbound requests, so a caller must treat the result as an attacker's +/// choice of string. +/// +/// The only checks applied here are the global cookie bounds, the length cap +/// and the cookie-safe alphabet in +/// [`ec_id_has_only_allowed_chars`](crate::ec::cookies::ec_id_has_only_allowed_chars), +/// which every identifier must satisfy whichever provider minted it. Those +/// bounds are a backstop on what may travel in a cookie, not a test of +/// authenticity, and on their own they accept any run of `[A-Za-z0-9._~-]`. /// -/// Returns `None` if neither source contains an EC ID. +/// Deciding whether this deployment issued the value needs the selected +/// provider, which this function does not have, so it is deliberately not +/// public. Use [`recognized_ec_id`], which applies provider ownership on top. /// /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { +pub(crate) fn unvalidated_ec_id_from_request( + req: &Request, +) -> Result, Report> { if let Some(ec_id) = req .headers() .get(HEADER_X_TS_EC) @@ -119,12 +132,20 @@ pub fn get_ec_id(req: &Request) -> Result, Report, ) -> Result, Report> { - let Some(ec_id) = get_ec_id(req)? else { + let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { return Ok(None); }; @@ -186,7 +207,7 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( services: &RuntimeServices, req: &Request, ) -> Result, Report> { - if let Some(id) = get_ec_id(req)? { + if let Some(id) = unvalidated_ec_id_from_request(req)? { return Ok(Some(id)); } @@ -387,12 +408,64 @@ mod tests { ); } + #[test] + fn an_identifier_this_deployment_never_issued_is_not_recognized() { + // `x-ts-ec` is stripped from responses but not from inbound requests, + // so a client can put whatever it likes in it, and the raw reader + // prefers the header over the cookie. The global cookie bounds accept + // any run of `[A-Za-z0-9._~-]`, so they cannot tell an identifier this + // deployment minted from one an attacker typed. Provider ownership is + // what draws that line. + let settings = create_test_settings(); + let services = noop_services(); + + for forged in [ + // Passes the alphabet and the length cap, owned by nobody. + "not-an-identifier", + // The built-in shape under another deployment's provider code. + "zz00~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234", + // This deployment's code carrying a value its provider never mints. + "hmac~not-the-hmac-shape", + ] { + let req = create_test_request(&[(HEADER_X_TS_EC, forged)]); + + // The raw reader hands it straight back, which is exactly why it is + // not the check anything may rely on. + assert_eq!( + unvalidated_ec_id_from_request(&req) + .expect("should read the header") + .as_deref(), + Some(forged), + "the global bounds alone should accept `{forged}`" + ); + + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring"), + None, + "`{forged}` was never issued here and must not be recognized" + ); + } + + // A value the selected provider does own is still recognized, so the + // check rejects forgeries rather than everything. + let issued = format!("hmac~{}.Ab1234", "a".repeat(64)); + let req = create_test_request(&[(HEADER_X_TS_EC, issued.as_str())]); + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring") + .as_deref(), + Some(issued.as_str()), + "an identifier the selected provider owns should still be recognized" + ); + } + #[test] fn test_get_ec_id_with_header() { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) @@ -409,7 +482,7 @@ mod tests { &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) @@ -427,7 +500,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); - let ec_id = get_ec_id(&req).expect("should get EC ID from http request"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should get EC ID from http request"); assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); } @@ -455,7 +529,7 @@ mod tests { #[test] fn test_get_ec_id_none() { let req = create_test_request(&[]); - let ec_id = get_ec_id(&req).expect("should handle missing ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } @@ -477,7 +551,8 @@ mod tests { (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); - let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should handle invalid header gracefully"); assert_eq!( ec_id, Some("valid_cookie_id".to_string()), @@ -510,7 +585,8 @@ mod tests { &format!("{}=bad`. + /// Injected at `` open, ahead of [`Self::ad_slots_script`] and the + /// tsjs bundle, so page code can read the request's permission state before + /// anything runs. `None` under a shared-template mode, where the head is + /// cached and served to many readers and nothing request-scoped may appear + /// in it, so the seam carries the state there instead. + pub permissions_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -226,6 +234,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + permissions_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, @@ -254,6 +263,17 @@ impl HtmlProcessorConfig { self } + /// Attach the head script carrying this request's permission state. + /// + /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are + /// independent decisions: the permission state travels on every HTML + /// document the processor handles, whether or not the ad stack ran. + #[must_use] + pub fn with_permissions_script(mut self, permissions_script: Option) -> Self { + self.permissions_script = permissions_script; + self + } + /// Set what the `` seam injects. /// /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are @@ -372,6 +392,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); + let permissions_script = config.permissions_script.clone(); let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); @@ -404,10 +425,17 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let patterns = patterns.clone(); let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); + let permissions_script = permissions_script.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The permission state goes first, ahead of the slots and + // the bundle, because both of those and any vendor module + // may read it as soon as they run. + if let Some(ref state_script) = permissions_script { + snippet.push_str(state_script); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -829,6 +857,7 @@ mod tests { request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1805,6 +1834,7 @@ mod tests { r#""# .to_string(), ), + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1882,6 +1912,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1921,6 +1952,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1959,6 +1991,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2015,6 +2048,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2045,6 +2079,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2070,6 +2105,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2206,6 +2242,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index e75ebc1f3..e95f61156 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -738,6 +738,42 @@ impl PermissionState { pub const fn permissions(&self) -> PermissionSet { self.set } + + /// The resolved state as the JSON the page receives in + /// `window.tsjs.permissions`. + /// + /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the + /// same state always serializes to the same bytes whatever order the set + /// was built in. An empty state + /// renders as `{"set":[]}`, which is an answer (nothing is set) rather than + /// a missing value, so page code never has to tell the two apart. + /// + /// This is the only place the page shape is spelled, so no caller writes + /// the JSON by hand. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::permissions::{ + /// Permission, PermissionSet, PermissionState, + /// }; + /// + /// let state = PermissionState::new( + /// PermissionSet::none().with(Permission::StoreOnDevice), + /// ); + /// assert_eq!( + /// state.page_json(), + /// r#"{"set":["necessary.operations.storage"]}"# + /// ); + /// + /// assert_eq!(PermissionState::default().page_json(), r#"{"set":[]}"#); + /// ``` + #[must_use] + pub fn page_json(&self) -> String { + let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); + names.sort_unstable(); + serde_json::json!({ "set": names }).to_string() + } } // --------------------------------------------------------------------------- @@ -966,6 +1002,8 @@ impl core::error::Error for PermissionsError {} #[cfg(test)] mod tests { + use serde_json::json; + use super::*; #[test] @@ -1009,6 +1047,49 @@ mod tests { ); } + #[test] + fn page_json_lists_set_permissions_sorted_by_name() { + // Arrange: a state whose bit-index order is the reverse of its name + // order, so the sort is what the assertion sees. + let state = PermissionState::new( + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds), + ); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": [ + "advertising_marketing.first_party.contextual", + "necessary.operations.storage", + ] + }) + .to_string(), + "should list every set permission by Data Use name, sorted" + ); + } + + #[test] + fn page_json_of_an_empty_state_is_an_empty_set() { + // Arrange + let state = PermissionState::default(); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ "set": [] }).to_string(), + "an empty state should render as an empty set, not as nothing" + ); + } + #[test] fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { // Empty maps and no default: every permission is the requires-signal diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 78d128722..a9adcf41d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -65,6 +65,7 @@ use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::IntegrationRegistry; +use crate::permissions::PermissionState; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, VarySpec, contains_publisher_esi_directive, @@ -622,6 +623,9 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, suppress_datadome_client_side_tag: bool, @@ -653,6 +657,7 @@ impl PublisherBodyProcessor { request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -734,6 +739,7 @@ fn process_response_streaming( request_scheme: params.request_scheme, settings: params.settings, integration_registry: params.integration_registry, + permissions_script: params.permissions_script.map(str::to_string), ad_slots_script: params.ad_slots_script.map(str::to_string), ad_bids_state: params.ad_bids_state.clone(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -1230,6 +1236,9 @@ struct HtmlStreamProcessorParams<'a> { request_scheme: &'a str, settings: &'a Settings, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option, ad_slots_script: Option, ad_bids_state: Arc>>, suppress_datadome_client_side_tag: bool, @@ -1384,6 +1393,20 @@ pub(crate) fn body_close_injection( } } +/// The head script this response's permission state belongs in, if any. +/// +/// Derived here rather than carried on [`OwnedProcessResponseParams`] so the state +/// has one representation on the request (the JSON) and the head-or-seam decision is +/// taken from the same effective mode both seams use. Under a shared-template mode +/// the answer is [`None`] and the seam carries the state instead. +fn permissions_script_for( + params: &OwnedProcessResponseParams, + settings: &Settings, +) -> Option { + let mode = effective_assembly_mode(settings, params.template_cache_key.is_some()); + template_permissions_script(mode, ¶ms.permissions_json) +} + fn create_html_stream_processor( params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { @@ -1410,6 +1433,7 @@ fn create_html_stream_processor( .flatten(); let config = config + .with_permissions_script(params.permissions_script) .with_ad_state(params.ad_slots_script, params.ad_bids_state) .with_gpt_diagnostics(gpt_diagnostics) .with_body_close(body_close) @@ -1585,6 +1609,14 @@ pub struct OwnedProcessResponseParams { /// /// Request-scoped, so it travels with the request rather than into the template. pub(crate) seam_ad_slots: Option, + /// This request's resolved permission state as page JSON, from + /// [`PermissionState::page_json`]. + /// + /// Carried as the JSON rather than as a rendered script because it is delivered in + /// two different places: the head under an inline response, and the `` seam + /// under a shared-template one. An empty string means no caller filled it in and + /// renders as the empty state. + pub(crate) permissions_json: String, /// Origin policy headers to store with the template and replay on a hit. pub(crate) policy_headers: Vec<(String, String)>, pub(crate) content_encoding: String, @@ -1996,21 +2028,28 @@ fn response_carries_a_seam_marker(was_authorized: bool, settings: &Settings) -> /// calls `scheduleInitialAdInit`, which schedules `adInit` for precisely the traffic /// that opted out. Absent is not the same as empty here. /// +/// It is never nothing at all any more, because the permission state has to reach the +/// page whether or not the ad stack ran, and a shared template's head cannot carry it. +/// The no-ad-stack answer is [`build_permissions_seam_script`], which sets the state and +/// schedules no ad init. +/// /// Shared by the miss path and by **both** hit finalizers. They previously each spelled /// the decision out, and the two hit paths spelled it `unwrap_or("[]")` — so the gate /// held on a cache miss and was ignored on every cache hit. fn seam_script_for(params: &OwnedProcessResponseParams) -> String { - params - .seam_ad_slots - .as_deref() - .map(|slots| params.ad_bids_state.build_seam_script(slots)) - .unwrap_or_default() + match params.seam_ad_slots.as_deref() { + Some(slots) => params + .ad_bids_state + .build_seam_script(slots, ¶ms.permissions_json), + None => build_permissions_seam_script(¶ms.permissions_json), + } } /// Builds the injection state a cached template needs on the way out. /// -/// The template carries no auction state — that is what makes it shareable — so the -/// per-reader parts are attached here, from this request. +/// The template carries no auction state and no permission state, which is what makes +/// it shareable, so the per-reader parts are attached here, from this request. Both +/// leave through the seam, never through the cached head. fn build_template_assembly_params( entry: &crate::platform::TemplateEntry, settings: &Settings, @@ -2018,6 +2057,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + permissions_json: String, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, @@ -2032,6 +2072,7 @@ fn build_template_assembly_params( request_scheme: request_scheme.to_string(), content_type: entry.metadata.content_type.clone(), // The template already carries the head seam; re-injecting would duplicate it. + permissions_json, ad_slots_script: None, ad_bids_state, auction_observation: None, @@ -2769,6 +2810,7 @@ pub fn stream_publisher_body( settings: &Settings, integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { + let permissions_script = permissions_script_for(params, settings); let borrowed = ProcessResponseParams { content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, @@ -2778,6 +2820,7 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + permissions_script: permissions_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: params.ad_bids_state.script_cell(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -2880,6 +2923,7 @@ pub async fn stream_publisher_body_async( request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -3117,8 +3161,8 @@ impl AdBidsState { } /// Build the shared-template seam, retaining the same debug prefix as inline. - fn build_seam_script(&self, slots_json: &str) -> String { - let seam = build_seam_script(slots_json, &self.bids()); + fn build_seam_script(&self, slots_json: &str, permissions_json: &str) -> String { + let seam = build_seam_script(slots_json, &self.bids(), permissions_json); let prefix = self .debug_prefix .lock() @@ -4092,6 +4136,11 @@ pub async fn handle_publisher_request( .filter(|_| ec_context.ec_sharing_allowed()); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); + // Resolved at the start of the request, so take it here, before the mutable + // borrows further down. Every HTML response carries it to the page, whether the + // ad stack runs or not, so the value is read once and cloned rather than + // recomputed per delivery point. + let permissions_json = ec_context.permissions().page_json(); let parsed_origin = url::Url::parse(&settings.publisher.origin_url).change_context( TrustedServerError::Proxy { @@ -4540,6 +4589,7 @@ pub async fn handle_publisher_request( request_scheme, price_granularity, ad_bids_state.clone(), + permissions_json.clone(), ); params.seam_ad_slots = seam_ad_slots.clone(); params.dispatched_auction = dispatched_auction.take(); @@ -4902,6 +4952,7 @@ pub async fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + permissions_json, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), suppress_datadome_client_side_tag, @@ -5420,6 +5471,7 @@ else t.bids=b;\ pub(crate) fn build_seam_script( slots_json: &str, bid_map: &serde_json::Map, + permissions_json: &str, ) -> String { // The local test script probes the minified `var a=JSON.parse`, // `var b=JSON.parse`, and `s(b,a)` literals below. Update the harness with any @@ -5429,17 +5481,40 @@ pub(crate) fn build_seam_script( format!( "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)), html_escape_for_script(slots_json), html_escape_for_script(&bids) ) } +/// Build the `` seam script for a request whose ad stack did not run. +/// +/// The head of a shared template carries nothing request-scoped, so the seam is +/// the only place this reader's permission state can be delivered. Before this +/// existed the seam was empty whenever the ad stack was skipped, which left a +/// bot-classified or permission-denied visitor with no state on the page at all. +/// +/// Carries the state and nothing else. It deliberately does not set `adSlots` or +/// `bids` and does not call `scheduleInitialAdInit`, because scheduling `adInit` +/// for traffic that opted out is what the gate in [`seam_script_for`] exists to +/// prevent. +pub(crate) fn build_permissions_seam_script(permissions_json: &str) -> String { + format!( + "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)) + ) +} + /// The slot definitions a shared-mode seam must carry, as JSON. /// /// Mirrors [`template_ad_slots_script`]'s gating: same `should_run_ad_stack` condition, @@ -6126,6 +6201,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 000000000..555b4e912 --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2490 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "build-print" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "thiserror", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.4", + "toml", + "validator", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iab_gpp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.119", + "thiserror", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissions-inspector-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "trusted-server-core", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-core" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "base64", + "brotli", + "bytes", + "chacha20poly1305", + "chrono", + "cookie", + "derive_more", + "ed25519-dalek", + "edgezero-core", + "error-stack", + "flate2", + "futures", + "getrandom 0.2.17", + "glob", + "hex", + "hmac", + "http", + "httpdate", + "iab_gpp", + "jose-jwk", + "log", + "lol_html", + "matchit", + "mime", + "rand", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "toml", + "trusted-server-js", + "trusted-server-openrtb", + "url", + "urlencoding", + "uuid", + "validator", + "web-time", +] + +[[package]] +name = "trusted-server-js" +version = "0.1.0" +dependencies = [ + "build-print", + "hex", + "sha2", + "which", +] + +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 000000000..f212c89db --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "permissions-inspector-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trusted-server-core = { path = "../../../crates/trusted-server-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 000000000..65abf8bda --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,37 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 000000000..928df80b1 --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,151 @@ +//! The permissions bit of Trusted Server, compiled to WebAssembly for the +//! inspector page. Inputs in, resulting permissions out, through the same +//! functions the server runs: `build_context_from_signals` decodes the raw +//! consent signals and `assemble_permissions` resolves the policy. + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The inspector's evaluation request. +#[derive(Deserialize)] +struct EvalInput { + /// `located`, `none`, or `failed`. + geo: String, + country: Option, + region: Option, + tc: Option, + gpp: Option, + us_privacy: Option, + #[serde(default)] + gpc: bool, +} + +fn eval_json(input: &str) -> String { + let input: EvalInput = match serde_json::from_str(input) { + Ok(input) => input, + Err(e) => return json!({"ok": false, "error": e.to_string()}).to_string(), + }; + let signals = RawConsentSignals { + raw_tc_string: input.tc.filter(|s| !s.is_empty()), + raw_gpp_string: input.gpp.filter(|s| !s.is_empty()), + raw_gpp_sid: None, + raw_us_privacy: input.us_privacy.filter(|s| !s.is_empty()), + gpc: input.gpc, + }; + let ctx = build_context_from_signals(&signals); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&ctx, GeoStatus::Failed); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + (state, jurisdiction_name(maps.default_jurisdiction())) + } + _ => { + let info = GeoInfo { + city: String::new(), + country: input.country.clone().unwrap_or_default(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: input.region.clone().filter(|r| !r.is_empty()), + asn: None, + }; + let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let jurisdiction = jurisdiction_name( + maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), + ); + (state, jurisdiction) + } + }; + let set: Vec<&'static str> = Permission::all() + .filter(|p| state.is_set(*p)) + .map(Permission::as_str) + .collect(); + json!({ + "ok": true, + "jurisdiction": jurisdiction, + "set": set, + "tcf_decoded": ctx.tcf.is_some(), + "malformed_record": ctx.has_malformed_record(), + }) + .to_string() +} + +fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { + let name = format!("{j:?}").to_lowercase(); + let name = name.split('(').next().unwrap_or(&name).to_string(); + name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") +} + +fn validate_json(yaml: &str) -> String { + match PermissionMaps::from_yaml(yaml) { + Ok(_) => json!({"ok": true}).to_string(), + Err(e) => json!({"ok": false, "error": e.to_string()}).to_string(), + } +} + +fn meta_json() -> String { + json!({ + "version": env!("TS_CORE_VERSION"), + "commit": env!("TS_CORE_COMMIT"), + "date": env!("TS_CORE_DATE"), + "branch": env!("TS_CORE_BRANCH"), + }) + .to_string() +} + +/// Leaks a length-prefixed buffer the host reads and then frees. +fn out(s: String) -> *mut u8 { + let bytes = s.into_bytes(); + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&bytes); + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_alloc(len: usize) -> *mut u8 { + let mut buf = vec![0u8; len]; + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must come from `ts_alloc` or an `out` buffer with capacity `len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_free(ptr: *mut u8, len: usize) { + unsafe { drop(Vec::from_raw_parts(ptr, len, len)) }; +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 JSON buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_eval(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(eval_json(core::str::from_utf8(input).unwrap_or("{}"))) +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 YAML buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_validate(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(validate_json(core::str::from_utf8(input).unwrap_or(""))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_meta() -> *mut u8 { + out(meta_json()) +} From 069ec9d11aa3b45df6c4ed42f562526d2228ac02 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 17:38:53 +0100 Subject: [PATCH 076/133] Mark the inspector build script executable CI failed with permission denied because the executable bit was never recorded in git, the usual Windows-authored-script gap. --- scripts/build-inspector-wasm.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build-inspector-wasm.sh diff --git a/scripts/build-inspector-wasm.sh b/scripts/build-inspector-wasm.sh old mode 100644 new mode 100755 From 9909f2e0cfe2ce32fad44967b429e6cae460f830 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 17:54:55 +0100 Subject: [PATCH 077/133] Limit the inspector workflow token to reading contents The code-scanning bot flagged the new workflow for carrying the default GITHUB_TOKEN permissions. The job only checks out and builds, so it reads contents and nothing else. --- .github/workflows/inspector.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml index 7b167d38c..5d58b3d93 100644 --- a/.github/workflows/inspector.yml +++ b/.github/workflows/inspector.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: build-inspector-wasm: name: build inspector wasm From aa0bce01d9091f48dcf3012ea68de658db3cdeb2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 20:35:36 +0100 Subject: [PATCH 078/133] Point the compiled-in rules doc at config/permissions The doc comment above the include_str still said the file lives at the repository root after the move to config/permissions/vanilla.yaml. --- crates/trusted-server-core/src/permissions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 00205b1f2..1c5dabc84 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -499,9 +499,9 @@ pub struct PermissionMaps { signals: SignalPolicy, } -/// The default permission rules, compiled into the build from the human-editable -/// `permissions.yaml` at the repository root. A deployer edits or replaces that -/// file to change the default policy; it is not read at runtime. +/// The default permission rules, compiled into the build from the repository's +/// vanilla sample in `config/permissions`. A deployer chooses or replaces the +/// compiled-in file to set the default policy; it is not read at runtime. const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/vanilla.yaml"); /// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. From 02f79c7515c73736338fafd691b1b28a7980a8f0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 14:03:17 +0100 Subject: [PATCH 079/133] Put the permission signal models behind a seam The permission model treats permissions as the primitive and says consent is only one of several ways a permission is established. The sources did not follow. TCF, a malformed record and the US-style opt-outs were decoded inline in one function, so a fifth way of learning a permission could not exist without editing core. PermissionSignalSource is that seam. It differs from the geo, device and identity seams in one way that matters: those select one implementation, because a request has one country and one device answer, while signals compose, because a request can carry a TCF string and a Global Privacy Control header at once and both have something to say. So the combining rule is written down rather than implied by the order of three if statements. A refusal beats a grant, a grant beats silence, and silence leaves the place baseline standing. That is not new policy: it is exactly the precedence the inline version had, where an opt-out revoked whatever TCF said and an absent signal changed nothing. Writing it as a rule is the point, because a rule takes a fourth source without being rewritten. The three existing models now sit behind the trait unchanged and core composes them itself, so a deployment configuring nothing behaves exactly as before. That was the bar for this change and 2,404 core tests plus the doctests say it is met. SignalPolicy and its accessors become public, because a seam meant to be implemented outside core has to hand a source something it can read. Clippy found that rather than a reviewer, by pointing out that a public field exposed a crate-private type. One thing nearly went wrong and is worth recording. The first draft had the TCF source return Neutral where the original returned Revoke for a purpose the visitor had not consented to. Silence would have left the place baseline standing and granted what they declined. An assertion on the exact original text caught it before it compiled, which is the argument for matching code you are replacing rather than retyping it from memory. Silence and refusal being different is now stated in the trait contract, because a source that confused them would revoke every permission it had no opinion on, on every request that did not carry its model. Verified: 2,404 core tests, 7 doctests, clippy clean at -D warnings, cargo fmt clean. --- crates/trusted-server-core/src/consent/mod.rs | 1 + .../trusted-server-core/src/consent/source.rs | 178 ++++++++++++++++++ crates/trusted-server-core/src/ec/consent.rs | 115 +++++++++-- crates/trusted-server-core/src/permissions.rs | 16 +- 4 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 crates/trusted-server-core/src/consent/source.rs diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index df98bd7aa..cde2a462a 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -34,6 +34,7 @@ mod extraction; pub mod gpp; pub mod jurisdiction; +pub mod source; pub mod tcf; pub mod types; pub mod us_privacy; diff --git a/crates/trusted-server-core/src/consent/source.rs b/crates/trusted-server-core/src/consent/source.rs new file mode 100644 index 000000000..94556a2e8 --- /dev/null +++ b/crates/trusted-server-core/src/consent/source.rs @@ -0,0 +1,178 @@ +//! Where a permission signal comes from. +//! +//! # Why this is a seam +//! +//! Permissions are the primitive, and consent is only one of several ways a +//! permission is established. The permission model says so and the language +//! throughout enforces it. The **sources** did not follow: TCF, GPP, US Privacy +//! and Global Privacy Control were decoded inside core and reachable only +//! through one struct, so a fifth way of learning a permission could not exist +//! without changing core. +//! +//! That is the same closed list the provider work already opened for geo, +//! device detection and Edge Cookie identity. This opens it for signals. +//! +//! # How sources differ from providers +//! +//! Geo, device and identity **select** one implementation: a request has one +//! country, one device answer, one identifier. Signals **compose**: a request +//! can carry a TCF string and a Global Privacy Control header at once, and both +//! have something to say. So a deployment collects a list rather than choosing +//! one, and [`combine`] decides what the list means together. +//! +//! # The combining rule +//! +//! A refusal beats a grant, and a grant beats silence: +//! +//! 1. Any source saying [`ConsentSignal::Revoke`] decides the answer. +//! 2. Otherwise any source saying [`ConsentSignal::Grant`] decides it. +//! 3. Otherwise [`ConsentSignal::Neutral`], and the place baseline stands. +//! +//! This is not a new policy. It is the precedence the hard-coded version +//! already had, written down: an opt-out revoked whatever TCF said, a malformed +//! record revoked, and an absent signal left the baseline alone. Expressing it +//! as a rule rather than as the order of three `if` statements is the point, +//! because a rule can take a fourth source without being rewritten. +//! +//! A source that has nothing to say about a permission returns `Neutral`, which +//! is different from `Revoke`. Silence is not refusal, and a source that +//! confused the two would revoke every permission it had no opinion on. + +use std::sync::Arc; + +use crate::permissions::{ConsentSignal, Permission, SignalPolicy}; + +use super::ConsentContext; + +/// What a signal source may read about a request. +/// +/// Deliberately a struct rather than a parameter list, so a source that needs +/// something new does not change every implementation. Today that is the +/// decoded consent record and the policy; a source reading a cookie of its own +/// will need the request, and adding it here will not disturb the four below. +pub struct SignalInput<'a> { + /// The decoded consent record for this request. + pub consent: &'a ConsentContext, + /// The policy from `permissions.yaml`, which decides what a signal means + /// rather than leaving each source to invent its own meaning. + pub policy: &'a SignalPolicy, +} + +/// A source of permission signals. +/// +/// An implementation answers for one signalling model. It reads the request, +/// applies whatever the policy says about its own model, and returns what it +/// knows about one permission. +/// +/// # Contract +/// +/// Answer `Neutral` for a permission this source has no opinion on, including +/// when the signal it reads is absent from the request. Returning `Revoke` for +/// an absent signal would turn silence into refusal and revoke every permission +/// on every request that did not carry this model. +pub trait PermissionSignalSource: Send + Sync { + /// Stable identifier, used in configuration and logs. + fn id(&self) -> &'static str; + + /// What this source knows about `permission` for this request. + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; +} + +/// The answer of several sources taken together. +/// +/// See the module documentation for the rule and why it is the existing +/// precedence rather than a new policy. +#[must_use] +pub fn combine( + sources: &[Arc], + permission: Permission, + input: &SignalInput<'_>, +) -> ConsentSignal { + let mut granted = false; + for source in sources { + match source.signal(permission, input) { + // A refusal is final and there is no point asking the rest. + ConsentSignal::Revoke => return ConsentSignal::Revoke, + ConsentSignal::Grant => granted = true, + ConsentSignal::Neutral => {} + } + } + if granted { + ConsentSignal::Grant + } else { + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A source that always answers the same thing, for testing the rule + /// rather than any particular model. + struct Fixed(&'static str, ConsentSignal); + + impl PermissionSignalSource for Fixed { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + self.1 + } + } + + fn sources(signals: &[ConsentSignal]) -> Vec> { + signals + .iter() + .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .collect() + } + + fn combined(signals: &[ConsentSignal]) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let input = SignalInput { + consent: &consent, + policy: &policy, + }; + combine(&sources(signals), Permission::StoreOnDevice, &input) + } + + #[test] + fn no_sources_leaves_the_place_baseline_alone() { + assert_eq!(combined(&[]), ConsentSignal::Neutral); + } + + #[test] + fn a_refusal_beats_a_grant_whatever_the_order() { + assert_eq!( + combined(&[ConsentSignal::Grant, ConsentSignal::Revoke]), + ConsentSignal::Revoke + ); + assert_eq!( + combined(&[ConsentSignal::Revoke, ConsentSignal::Grant]), + ConsentSignal::Revoke, + "an opt-out revoked whatever TCF said, and that has to survive the refactor" + ); + } + + #[test] + fn a_grant_beats_silence() { + assert_eq!( + combined(&[ConsentSignal::Neutral, ConsentSignal::Grant]), + ConsentSignal::Grant + ); + } + + #[test] + fn silence_from_every_source_is_not_a_refusal() { + // The failure this guards is a source that reads an absent signal as a + // refusal. It would revoke every permission on every request that did + // not carry that model, which is most of them. + assert_eq!( + combined(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), + ConsentSignal::Neutral + ); + } +} diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index f87d811d5..917b28dda 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -10,6 +10,9 @@ use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; +use std::sync::Arc; + +use crate::consent::source::{PermissionSignalSource, SignalInput}; use crate::permissions::{ Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -157,30 +160,110 @@ fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, ) -> impl Fn(Permission) -> ConsentSignal + 'a { + let sources = builtin_sources(); move |permission| { - if opt_out_present(consent, signals.opt_out_sources()) - && signals.opt_out_revokes(permission) + let input = SignalInput { + consent, + policy: signals, + }; + crate::consent::source::combine(&sources, permission, &input) + } +} + +/// The signal models core supplies itself. +/// +/// These four were decoded inline until the seam existed. They are the same +/// rules, moved behind [`PermissionSignalSource`] so a fifth model can be added +/// by a module instead of by editing core. A deployment that configures nothing +/// gets exactly these, in this order, which is why the refactor is invisible. +/// +/// The order does not decide the outcome, because [`combine`] lets a refusal +/// win wherever it appears. It is kept as it was for readability. +/// +/// [`combine`]: crate::consent::source::combine +/// [`PermissionSignalSource`]: crate::consent::source::PermissionSignalSource +fn builtin_sources() -> Vec> { + vec![ + Arc::new(UsOptOutSource), + Arc::new(MalformedRecordSource), + Arc::new(TcfSource), + ] +} + +/// A US-style opt-out, from Global Privacy Control, a GPP sale opt-out, or a +/// US Privacy string. +/// +/// Which of those count, and what an opt-out takes away, are both the policy's +/// decisions rather than this source's. It only reads whether one is present. +struct UsOptOutSource; + +impl PermissionSignalSource for UsOptOutSource { + fn id(&self) -> &'static str { + "us-opt-out" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if opt_out_present(input.consent, input.policy.opt_out_sources()) + && input.policy.opt_out_revokes(permission) { return ConsentSignal::Revoke; } - if consent.has_malformed_record() { + ConsentSignal::Neutral + } +} + +/// A consent record that arrived and could not be read. +/// +/// Distinct from no record at all. An absent record is silence and leaves the +/// place baseline standing. A record that is present and malformed is a signal +/// we cannot trust, so it revokes rather than being ignored. +struct MalformedRecordSource; + +impl PermissionSignalSource for MalformedRecordSource { + fn id(&self) -> &'static str { + "malformed-record" + } + + fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if input.consent.has_malformed_record() { return ConsentSignal::Revoke; } - if signals.tcf_authoritative() - && let Some(tcf) = crate::consent::effective_tcf(consent) - { - return match signals.tcf_purpose(permission) { - Some(purpose) => { - if tcf.has_purpose_consent(usize::from(purpose)) { - ConsentSignal::Grant - } else { - ConsentSignal::Revoke - } + ConsentSignal::Neutral + } +} + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is the policy's, so this source +/// decodes and does not interpret. A permission no purpose maps to gets +/// silence, not a refusal. +struct TcfSource; + +impl PermissionSignalSource for TcfSource { + fn id(&self) -> &'static str { + "tcf" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(tcf) = crate::consent::effective_tcf(input.consent) else { + return ConsentSignal::Neutral; + }; + match input.policy.tcf_purpose(permission) { + Some(purpose) => { + if tcf.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal, + // not silence. Reading it as silence would leave the place + // baseline standing and grant what they declined. + ConsentSignal::Revoke } - None => ConsentSignal::Neutral, - }; + } + None => ConsentSignal::Neutral, } - ConsentSignal::Neutral } } diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1c5dabc84..1d441530c 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -384,7 +384,7 @@ enum RevokeSet { /// it says only how a decoded signal grants or revokes each Data Use, and the /// country/region baseline decides the rest. #[derive(Debug, Clone, Default)] -pub(crate) struct SignalPolicy { +pub struct SignalPolicy { /// Whether a present TCF record's grants and revokes apply. This never /// lets a TCF record override an opt-out signal: an opt-out always /// suppresses the Data Uses it revokes. @@ -399,23 +399,27 @@ pub(crate) struct SignalPolicy { impl SignalPolicy { /// Whether a present TCF record's grants and revokes apply. - pub(crate) fn tcf_authoritative(&self) -> bool { + #[must_use] + pub fn tcf_authoritative(&self) -> bool { self.tcf_authoritative } /// The TCF purpose number that grants `permission`, or `None` when no purpose /// maps to it. - pub(crate) fn tcf_purpose(&self, permission: Permission) -> Option { + #[must_use] + pub fn tcf_purpose(&self, permission: Permission) -> Option { self.tcf_purpose.get(&permission.index()).copied() } /// The signals that constitute a US-style opt-out. - pub(crate) fn opt_out_sources(&self) -> &[OptOutSource] { + #[must_use] + pub fn opt_out_sources(&self) -> &[OptOutSource] { &self.opt_out_sources } /// Whether a US-style opt-out revokes `permission`. - pub(crate) fn opt_out_revokes(&self, permission: Permission) -> bool { + #[must_use] + pub fn opt_out_revokes(&self, permission: Permission) -> bool { match &self.opt_out_revokes { RevokeSet::None => false, RevokeSet::All => true, @@ -945,7 +949,7 @@ impl Default for RevokeSpec { /// A single US-style opt-out signal source. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OptOutSource { +pub enum OptOutSource { /// The `Sec-GPC` request header (Global Privacy Control). Gpc, /// A GPP US sale opt-out. From da4ea02e57c5c2c0c13c325d0c9f23f539908b2d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 14:56:59 +0100 Subject: [PATCH 080/133] Evaluate permission signals in order, each amending the last The seam added in the previous commit merged every source's answer with a refusal winning wherever it appeared, so the order of the list carried no meaning and a later signal could never amend an earlier one. That made a prompt answer unable to supersede a browser setting, which is the case the seam exists for. Permissions now resolve in layers. The country and region rules give the baseline, then each source is asked in configured order and amends what the ones before it settled on. A source with no opinion returns Neutral and leaves the prior value standing, which stays distinct from refusing. A source is given the baseline, the settled value, the whole ordered list and its own position in it, so it can look a peer up by name, tell a peer that is absent from one that is silent, and ask a peer directly what that peer makes of a permission. That is what makes a rule such as "personalisation is off, but only because Global Privacy Control set it, so my answer supersedes it" expressible by whichever source wants it, rather than fixed here. A consultation goes one level deep, so two sources that consult each other settle instead of recursing. Renamed out from under consent to permission_signal. Global Privacy Control is a setting a browser sends rather than an answer anyone gave to a question, so consent is one kind of signal arriving here and not the name for all of them. The README is the module documentation, so there is one copy of the explanation for the vendor crates to point at. Three tests asserted the opposite rule, that an opt-out suppressed storage whatever else the request carried. That rule was written in a comment beside them and nowhere else; the only specification covering precedence, docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md, sets out a fixed chain and says a publisher needing different precedence should raise it as a configuration change. They now assert the ordered rule. resolve_with, floor_with and resolve_rules pass the baseline to the signal closure, since a source cannot amend what it cannot see. cargo fmt clean, clippy clean on fastly, axum, cloudflare, cloudflare-wasm and spin-native, 2410 core tests, 166 fastly, 40 axum. --- crates/trusted-server-core/src/consent/mod.rs | 1 - .../trusted-server-core/src/consent/source.rs | 178 -------- crates/trusted-server-core/src/ec/consent.rs | 97 ++--- crates/trusted-server-core/src/lib.rs | 1 + .../src/permission_signal/README.md | 91 ++++ .../src/permission_signal/mod.rs | 410 ++++++++++++++++++ crates/trusted-server-core/src/permissions.rs | 30 +- 7 files changed, 566 insertions(+), 242 deletions(-) delete mode 100644 crates/trusted-server-core/src/consent/source.rs create mode 100644 crates/trusted-server-core/src/permission_signal/README.md create mode 100644 crates/trusted-server-core/src/permission_signal/mod.rs diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index cde2a462a..df98bd7aa 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -34,7 +34,6 @@ mod extraction; pub mod gpp; pub mod jurisdiction; -pub mod source; pub mod tcf; pub mod types; pub mod us_privacy; diff --git a/crates/trusted-server-core/src/consent/source.rs b/crates/trusted-server-core/src/consent/source.rs deleted file mode 100644 index 94556a2e8..000000000 --- a/crates/trusted-server-core/src/consent/source.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Where a permission signal comes from. -//! -//! # Why this is a seam -//! -//! Permissions are the primitive, and consent is only one of several ways a -//! permission is established. The permission model says so and the language -//! throughout enforces it. The **sources** did not follow: TCF, GPP, US Privacy -//! and Global Privacy Control were decoded inside core and reachable only -//! through one struct, so a fifth way of learning a permission could not exist -//! without changing core. -//! -//! That is the same closed list the provider work already opened for geo, -//! device detection and Edge Cookie identity. This opens it for signals. -//! -//! # How sources differ from providers -//! -//! Geo, device and identity **select** one implementation: a request has one -//! country, one device answer, one identifier. Signals **compose**: a request -//! can carry a TCF string and a Global Privacy Control header at once, and both -//! have something to say. So a deployment collects a list rather than choosing -//! one, and [`combine`] decides what the list means together. -//! -//! # The combining rule -//! -//! A refusal beats a grant, and a grant beats silence: -//! -//! 1. Any source saying [`ConsentSignal::Revoke`] decides the answer. -//! 2. Otherwise any source saying [`ConsentSignal::Grant`] decides it. -//! 3. Otherwise [`ConsentSignal::Neutral`], and the place baseline stands. -//! -//! This is not a new policy. It is the precedence the hard-coded version -//! already had, written down: an opt-out revoked whatever TCF said, a malformed -//! record revoked, and an absent signal left the baseline alone. Expressing it -//! as a rule rather than as the order of three `if` statements is the point, -//! because a rule can take a fourth source without being rewritten. -//! -//! A source that has nothing to say about a permission returns `Neutral`, which -//! is different from `Revoke`. Silence is not refusal, and a source that -//! confused the two would revoke every permission it had no opinion on. - -use std::sync::Arc; - -use crate::permissions::{ConsentSignal, Permission, SignalPolicy}; - -use super::ConsentContext; - -/// What a signal source may read about a request. -/// -/// Deliberately a struct rather than a parameter list, so a source that needs -/// something new does not change every implementation. Today that is the -/// decoded consent record and the policy; a source reading a cookie of its own -/// will need the request, and adding it here will not disturb the four below. -pub struct SignalInput<'a> { - /// The decoded consent record for this request. - pub consent: &'a ConsentContext, - /// The policy from `permissions.yaml`, which decides what a signal means - /// rather than leaving each source to invent its own meaning. - pub policy: &'a SignalPolicy, -} - -/// A source of permission signals. -/// -/// An implementation answers for one signalling model. It reads the request, -/// applies whatever the policy says about its own model, and returns what it -/// knows about one permission. -/// -/// # Contract -/// -/// Answer `Neutral` for a permission this source has no opinion on, including -/// when the signal it reads is absent from the request. Returning `Revoke` for -/// an absent signal would turn silence into refusal and revoke every permission -/// on every request that did not carry this model. -pub trait PermissionSignalSource: Send + Sync { - /// Stable identifier, used in configuration and logs. - fn id(&self) -> &'static str; - - /// What this source knows about `permission` for this request. - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; -} - -/// The answer of several sources taken together. -/// -/// See the module documentation for the rule and why it is the existing -/// precedence rather than a new policy. -#[must_use] -pub fn combine( - sources: &[Arc], - permission: Permission, - input: &SignalInput<'_>, -) -> ConsentSignal { - let mut granted = false; - for source in sources { - match source.signal(permission, input) { - // A refusal is final and there is no point asking the rest. - ConsentSignal::Revoke => return ConsentSignal::Revoke, - ConsentSignal::Grant => granted = true, - ConsentSignal::Neutral => {} - } - } - if granted { - ConsentSignal::Grant - } else { - ConsentSignal::Neutral - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A source that always answers the same thing, for testing the rule - /// rather than any particular model. - struct Fixed(&'static str, ConsentSignal); - - impl PermissionSignalSource for Fixed { - fn id(&self) -> &'static str { - self.0 - } - - fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { - self.1 - } - } - - fn sources(signals: &[ConsentSignal]) -> Vec> { - signals - .iter() - .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) - .collect() - } - - fn combined(signals: &[ConsentSignal]) -> ConsentSignal { - let consent = ConsentContext::default(); - let policy = SignalPolicy::default(); - let input = SignalInput { - consent: &consent, - policy: &policy, - }; - combine(&sources(signals), Permission::StoreOnDevice, &input) - } - - #[test] - fn no_sources_leaves_the_place_baseline_alone() { - assert_eq!(combined(&[]), ConsentSignal::Neutral); - } - - #[test] - fn a_refusal_beats_a_grant_whatever_the_order() { - assert_eq!( - combined(&[ConsentSignal::Grant, ConsentSignal::Revoke]), - ConsentSignal::Revoke - ); - assert_eq!( - combined(&[ConsentSignal::Revoke, ConsentSignal::Grant]), - ConsentSignal::Revoke, - "an opt-out revoked whatever TCF said, and that has to survive the refactor" - ); - } - - #[test] - fn a_grant_beats_silence() { - assert_eq!( - combined(&[ConsentSignal::Neutral, ConsentSignal::Grant]), - ConsentSignal::Grant - ); - } - - #[test] - fn silence_from_every_source_is_not_a_refusal() { - // The failure this guards is a source that reads an absent signal as a - // refusal. It would revoke every permission on every request that did - // not carry that model, which is most of them. - assert_eq!( - combined(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), - ConsentSignal::Neutral - ); - } -} diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 917b28dda..2ab0b747e 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -12,7 +12,7 @@ use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; use std::sync::Arc; -use crate::consent::source::{PermissionSignalSource, SignalInput}; +use crate::permission_signal::{PermissionSignalSource, SignalInput}; use crate::permissions::{ Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -136,52 +136,38 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { /// only decodes the request and applies that policy, so no signal-to-permission /// policy lives in the code. /// -/// It considers every source the policy names: a TCF record (a standalone TC -/// string or the EU TCF section of a GPP string), and the US-style opt-out -/// signals (GPC, a GPP sale opt-out, or a US Privacy opt-out). Precedence is -/// most-restrictive-first and is fixed in code, not policy: +/// The models it asks are the ones core supplies (see [`builtin_sources`]), +/// each of which amends what the ones before it settled on. The order is the +/// policy, and [`combine`] documents why. This function only assembles the +/// list and hands each source the request. /// -/// 1. A US-style opt-out revokes the Data Uses the policy lists, even when a -/// TCF record consents. An opt-out is an explicit user signal, so no other -/// signal may override it. -/// 2. A consent record that is present but cannot be decoded revokes -/// everything, so an unreadable expression of preference fails closed -/// instead of degrading to the no-signal baseline. -/// 3. When the policy marks TCF authoritative, a present TCF record then -/// decides the mapped Data Uses: granted where the record consents to the -/// mapped purpose, revoked where it does not, and neutral where no purpose -/// is mapped. The `authoritative` flag governs only whether TCF grants and -/// revokes apply, never whether an opt-out may be overridden. +/// Whether an amendment changes anything is then decided by the country/region +/// map, which drops a `granted` baseline on a `Revoke` and has nothing to drop +/// where the permission is `requires_signal` or `denied`. /// -/// Whether a `Revoke` changes anything is decided by the country/region map, -/// which drops a `granted` baseline and has nothing to drop where the -/// permission is `requires_signal` or `denied`. +/// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, -) -> impl Fn(Permission) -> ConsentSignal + 'a { +) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { let sources = builtin_sources(); - move |permission| { - let input = SignalInput { - consent, - policy: signals, - }; - crate::consent::source::combine(&sources, permission, &input) + move |permission, baseline| { + crate::permission_signal::combine(&sources, permission, consent, signals, baseline) } } /// The signal models core supplies itself. /// -/// These four were decoded inline until the seam existed. They are the same -/// rules, moved behind [`PermissionSignalSource`] so a fifth model can be added -/// by a module instead of by editing core. A deployment that configures nothing -/// gets exactly these, in this order, which is why the refactor is invisible. +/// These were decoded inline until the seam existed. They are the same rules, +/// moved behind [`PermissionSignalSource`] so a further model can be added by a +/// module instead of by editing core. /// -/// The order does not decide the outcome, because [`combine`] lets a refusal -/// win wherever it appears. It is kept as it was for readability. +/// The order runs the signals needing no interaction before the ones following +/// a prompt, so a visitor who arrives with an opt-out and then answers a prompt +/// has their answer applied. A deployment wanting the opposite puts the opt-out +/// source last. /// -/// [`combine`]: crate::consent::source::combine -/// [`PermissionSignalSource`]: crate::consent::source::PermissionSignalSource +/// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource fn builtin_sources() -> Vec> { vec![ Arc::new(UsOptOutSource), @@ -401,15 +387,24 @@ mod tests { } // ------------------------------------------------------------------ - // Opt-out precedence pinning tests. These reinstate the behavior the - // consent module enforced before the permission model: an explicit - // opt-out signal suppresses storage and sharing even when a TCF record - // consents. The permission model must never let a CMP-written record - // override the visitor's own opt-out. + // Opt-out and prompt precedence. + // + // These replace an earlier set asserting the opposite, that an opt-out + // suppressed storage and sharing whatever else the request carried. The + // sources are now asked in order and each amends what the ones before it + // settled, so a later source can amend an opt-out. + // + // The default order asks the signals needing no interaction first and the + // ones following a prompt after, which is why a visitor who arrives with + // an opt-out and then answers a prompt has their answer applied. A + // deployment wanting the opposite puts the opt-out source last. + // + // The layering, the configuration and what a source may consult are in + // crates/trusted-server-core/src/permission_signal/README.md. // ------------------------------------------------------------------ #[test] - fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_gpc_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), gpc: true, @@ -418,14 +413,14 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "GPC should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" ); } #[test] - fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), us_privacy: Some(crate::consent::types::UsPrivacy { @@ -439,14 +434,14 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "a US Privacy opt-out should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under the \ + default order the answer they gave amends the signal they sent" ); } #[test] - fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), gpp: Some(crate::consent::types::GppConsent { @@ -460,9 +455,9 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "a GPP sale opt-out should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under the \ + default order the answer they gave amends the signal they sent" ); } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 31bc119ae..47c0d5e85 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod permission_signal; pub mod permissions; pub mod platform; pub mod price_bucket; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md new file mode 100644 index 000000000..ddffce6f3 --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -0,0 +1,91 @@ +# Permission signals + +A permission is the primitive. This is the seam that decides whether one is +set, and it is open so that a new way of learning a permission can be added +without changing core. + +"Signal" rather than "consent" because consent is only one of the things +arriving here. Global Privacy Control is a setting a browser sends, not an +answer anyone gave to a question, and a jurisdiction rule is neither. Consent +is one kind of signal, so the seam takes the wider name and the consent +subsystem keeps the narrower one. + +## The hierarchy + +Permissions are resolved in layers, each amending the one before. + +```text + country / region rules the baseline: granted, requires-signal, denied + | + v + source 1 (configured order) may amend + | + v + source 2 may amend + | + v + ... may amend + | + v + the permission state for this request +``` + +The baseline comes from `permissions.yaml`, keyed by country and region, with +a default for a request whose place is unknown. A geo provider supplies the +place. No geo provider means no country, and the baseline falls to its floor. + +Sources are then asked in order. Each sees what the sources before it settled +on and may amend it. A source with no opinion returns `Neutral` and leaves the +prior value standing, which is different from refusing. + +## The order is the policy + +The last source with an opinion decides, so the order is the policy. It is a +deployment's to set, not this code's to assume. + +Today the order is the order of the list handed to `combine`, which core +assembles itself. Naming the sources in operator configuration arrives with +the split into vendor crates, because a name in a configuration file is only +useful once a source can come from outside core. + +The default order asks the signals needing no interaction first and the ones +following a prompt after. Global Privacy Control withdraws personalisation on +arrival, and a visitor who then answers a prompt has their answer applied over +it. A deployment wanting the opposite puts the opt-out source last. + +Trusted Server takes no view on which model should win. That is a question +about a jurisdiction and a publisher. + +## A source can see the others + +Amending well sometimes needs to know who set the prior value. A source is +given the whole ordered list and its own position in it, so it can look up a +peer by name, see whether a peer it cares about is configured at all, and ask +a peer directly what that peer makes of a permission. + +That is what makes a rule like "personalisation is off, but only because +Global Privacy Control set it, so my answer supersedes it" expressible. The +rule itself belongs to whichever source wants it. This seam only makes the +information available. + +Consulting a peer goes one level deep. A source answering a consultation +cannot consult in turn, so two sources asking each other cannot loop. + +## Writing a source + +Answer `Neutral` for a permission the source has no opinion on, including when +the signal it reads is absent from the request. Returning `Revoke` for an +absent signal turns silence into refusal and would revoke the permission on +every request not carrying that model, which is most of them. + +Read the policy for what a signal means rather than inventing a meaning. The +mapping from a permission to a TCF purpose, and which signals count as an +opt-out, are the policy's decisions so that a deployment can change them +without changing a source. + +## The models supplied + +Core supplies the models it already understood: a US-style opt-out (Global +Privacy Control, a GPP sale opt-out, or a US Privacy string), a consent record +that arrived unreadable, and TCF v2. A deployment configuring nothing gets +those, in that order. diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs new file mode 100644 index 000000000..f8fac0af0 --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -0,0 +1,410 @@ +#![doc = include_str!("README.md")] + +use std::sync::Arc; + +use crate::consent::ConsentContext; +use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; + +/// What a signal source may read about a request. +/// +/// A struct rather than a parameter list, so a source needing something new +/// does not change every implementation. +pub struct SignalInput<'a> { + /// The decoded consent record for this request. + pub consent: &'a ConsentContext, + /// The policy from `permissions.yaml`, which decides what a signal means + /// rather than leaving each source to invent its own meaning. + pub policy: &'a SignalPolicy, + /// What the country and region rules say about this permission, before any + /// source is asked. A source amends this rather than deciding alone. + pub baseline: Acquisition, + /// What the sources asked before this one settled on. + /// + /// [`ConsentSignal::Neutral`] means none of them had an opinion, so the + /// baseline still stands. + pub settled: ConsentSignal, + /// Every source in configured order, this one included. + sources: &'a [Arc], + /// Where in that order the source being asked sits. + position: usize, + /// Whether this source may consult a peer. + /// + /// False while answering a consultation, which is what stops two sources + /// that consult each other from looping. + may_ask: bool, +} + +impl<'a> SignalInput<'a> { + /// An input for a source asked on its own, outside an ordered run. + #[must_use] + pub fn new( + consent: &'a ConsentContext, + policy: &'a SignalPolicy, + baseline: Acquisition, + ) -> Self { + Self { + consent, + policy, + baseline, + settled: ConsentSignal::Neutral, + sources: &[], + position: 0, + may_ask: true, + } + } + + /// Every source in configured order, this one included. + /// + /// A source consults the list to decide whether a peer it cares about is + /// configured at all, and where it sits relative to this one. + #[must_use] + pub fn sources(&self) -> &[Arc] { + self.sources + } + + /// Where the source being asked sits in that order. + #[must_use] + pub const fn position(&self) -> usize { + self.position + } + + /// Whether a source with this identifier is configured. + #[must_use] + pub fn has(&self, id: &str) -> bool { + self.sources.iter().any(|source| source.id() == id) + } + + /// What a peer makes of `permission`, asked directly. + /// + /// The peer answers as if it were first, so the reply is that peer's own + /// opinion rather than what the run has settled on so far. That is the + /// useful question: a source wanting to know whether the prior value came + /// from a particular peer asks that peer what it says. + /// + /// Returns `None` when no source carries the identifier, when the caller + /// names itself, and when this input is itself answering a consultation. + /// A source must handle `None` rather than assume a peer is present. + #[must_use] + pub fn ask(&self, id: &str, permission: Permission) -> Option { + if !self.may_ask { + return None; + } + let (position, source) = self + .sources + .iter() + .enumerate() + .find(|(position, source)| source.id() == id && *position != self.position)?; + let input = Self { + consent: self.consent, + policy: self.policy, + baseline: self.baseline, + settled: ConsentSignal::Neutral, + sources: self.sources, + position, + may_ask: false, + }; + Some(source.signal(permission, &input)) + } +} + +/// A source of permission signals. +/// +/// An implementation answers for one signalling model. It reads the request, +/// applies whatever the policy says about its own model, and returns how it +/// would amend one permission. +/// +/// # Contract +/// +/// Answer [`ConsentSignal::Neutral`] for a permission this source has no +/// opinion on, including when the signal it reads is absent from the request. +/// Returning [`ConsentSignal::Revoke`] for an absent signal would turn silence +/// into refusal and revoke the permission on every request that did not carry +/// this model. +pub trait PermissionSignalSource: Send + Sync { + /// Stable identifier, used in configuration, in logs, and by a peer + /// looking this source up through [`SignalInput::ask`]. + fn id(&self) -> &'static str; + + /// How this source would amend `permission` for this request. + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; +} + +/// Asks every source in order and returns what they settle on together. +/// +/// See the module documentation for the layering and why the order is the +/// configuration. +#[must_use] +pub fn combine( + sources: &[Arc], + permission: Permission, + consent: &ConsentContext, + policy: &SignalPolicy, + baseline: Acquisition, +) -> ConsentSignal { + let mut settled = ConsentSignal::Neutral; + for (position, source) in sources.iter().enumerate() { + let input = SignalInput { + consent, + policy, + baseline, + settled, + sources, + position, + may_ask: true, + }; + // Every source is asked, because a later one may amend what an earlier + // one settled. Stopping at the first answer would make the order mean + // the opposite of what it says. + match source.signal(permission, &input) { + ConsentSignal::Neutral => {} + answer => settled = answer, + } + } + settled +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A source that always answers the same thing, for testing the rule + /// rather than any particular model. + struct Fixed(&'static str, ConsentSignal); + + impl PermissionSignalSource for Fixed { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + self.1 + } + } + + /// A source that answers by consulting a peer, which is the behavior the + /// peer visibility exists for. + struct Consulting { + id: &'static str, + peer: &'static str, + } + + impl PermissionSignalSource for Consulting { + fn id(&self) -> &'static str { + self.id + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + match input.ask(self.peer, permission) { + // The peer refused, and this source takes the opposite view of + // the same request, which is the override the seam allows. + Some(ConsentSignal::Revoke) => ConsentSignal::Grant, + _ => ConsentSignal::Neutral, + } + } + } + + fn combined(sources: &[Arc]) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + combine( + sources, + Permission::StoreOnDevice, + &consent, + &policy, + Acquisition::RequiresSignal, + ) + } + + /// The same, for a run whose sources differ only in what they answer. + fn combined_signals(signals: &[ConsentSignal]) -> ConsentSignal { + let sources: Vec> = signals + .iter() + .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .collect(); + combined(&sources) + } + + #[test] + fn no_sources_leaves_the_place_baseline_alone() { + assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); + } + + #[test] + fn the_last_source_with_an_opinion_decides() { + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Grant]), + ConsentSignal::Grant, + "a visitor who answers a prompt after arriving with an opt-out header has \ + their answer applied over it, which is why the order is the policy" + ); + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Revoke]), + ConsentSignal::Revoke, + "and a deployment that wants the opt-out to win puts it last" + ); + } + + #[test] + fn silence_leaves_an_earlier_answer_standing() { + // The failure this guards is a later source overwriting a settled + // answer with its own absence, which would let adding a source nobody + // uses undo the one that was working. + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), + ConsentSignal::Grant + ); + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Neutral]), + ConsentSignal::Revoke + ); + } + + #[test] + fn silence_from_every_source_is_not_a_refusal() { + // The failure this guards is a source that reads an absent signal as a + // refusal. It would revoke the permission on every request that did not + // carry that model, which is most of them. + assert_eq!( + combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), + ConsentSignal::Neutral + ); + } + + #[test] + fn a_source_sees_what_the_earlier_ones_settled() { + struct Recording; + + impl PermissionSignalSource for Recording { + fn id(&self) -> &'static str { + "recording" + } + + fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert_eq!( + input.settled, + ConsentSignal::Revoke, + "a source is asked with the value the sources before it settled on" + ); + assert_eq!(input.position(), 1, "and with its own place in the order"); + assert_eq!(input.sources().len(), 2, "and with the whole list"); + assert_eq!( + input.baseline, + Acquisition::RequiresSignal, + "and with what the place rules said before anyone was asked" + ); + ConsentSignal::Neutral + } + } + + let sources: Vec> = vec![ + Arc::new(Fixed("opt-out", ConsentSignal::Revoke)), + Arc::new(Recording), + ]; + assert_eq!(combined(&sources), ConsentSignal::Revoke); + } + + #[test] + fn a_source_can_override_a_peer_by_consulting_it() { + // The worked example from the README: a later source overrides a + // refusal because of who made it, not merely that one was made. + let sources: Vec> = vec![ + Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Grant); + + // The same source leaves the refusal alone when it came from a peer it + // was not told to override. + let sources: Vec> = vec![ + Arc::new(Fixed("other", ConsentSignal::Revoke)), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Revoke); + } + + #[test] + fn asking_reaches_a_peer_wherever_it_sits_in_the_order() { + // A source may consult one configured after it, not only before, so a + // reordering does not silently change what a source can see. + let sources: Vec> = vec![ + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + ]; + assert_eq!( + combined(&sources), + ConsentSignal::Revoke, + "the consultation succeeded, and the later opt-out then settled it" + ); + } + + #[test] + fn asking_for_a_source_that_is_not_configured_answers_nothing() { + struct Absent; + + impl PermissionSignalSource for Absent { + fn id(&self) -> &'static str { + "absent" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert!( + input.ask("not-configured", permission).is_none(), + "a source must be able to tell a missing peer from a silent one" + ); + assert!(!input.has("not-configured")); + assert!(input.has("absent"), "and can see itself in the list"); + ConsentSignal::Neutral + } + } + + let sources: Vec> = vec![Arc::new(Absent)]; + assert_eq!(combined(&sources), ConsentSignal::Neutral); + } + + #[test] + fn a_source_cannot_consult_itself() { + struct SelfAsking; + + impl PermissionSignalSource for SelfAsking { + fn id(&self) -> &'static str { + "self-asking" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // Without the guard this recurses until the stack is gone. + assert!(input.ask("self-asking", permission).is_none()); + ConsentSignal::Grant + } + } + + let sources: Vec> = vec![Arc::new(SelfAsking)]; + assert_eq!(combined(&sources), ConsentSignal::Grant); + } + + #[test] + fn two_sources_that_consult_each_other_do_not_loop() { + // Each consults the other, and the one answering a consultation is + // refused a consultation of its own, so the pair settles instead of + // recursing. + let sources: Vec> = vec![ + Arc::new(Consulting { + id: "first", + peer: "second", + }), + Arc::new(Consulting { + id: "second", + peer: "first", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Neutral); + } +} diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1d441530c..1907302ce 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -710,7 +710,7 @@ impl PermissionMaps { &self, country: Option<&str>, region: Option<&str>, - signal: impl Fn(Permission) -> ConsentSignal, + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, ) -> PermissionState { Self::resolve_rules(self.rules_or_default(country, region), signal) } @@ -723,7 +723,9 @@ impl PermissionMaps { /// must not paper over, so nothing is set unless the session's signals /// grant it. #[must_use] - pub fn floor_with(signal: impl Fn(Permission) -> ConsentSignal) -> PermissionState { + pub fn floor_with( + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, + ) -> PermissionState { Self::resolve_rules(None, signal) } @@ -731,20 +733,24 @@ impl PermissionMaps { /// when no rules resolved. fn resolve_rules( rules: Option<&CountryRules>, - signal: impl Fn(Permission) -> ConsentSignal, + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, ) -> PermissionState { let acquisition = |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); let set = Permission::all() - .filter( - |&permission| match (acquisition(permission), signal(permission)) { + .filter(|&permission| { + // The baseline is passed to the signal as well as applied to + // its answer, because a source amends the place rules and + // cannot amend what it cannot see. + let baseline = acquisition(permission); + match (baseline, signal(permission, baseline)) { (Acquisition::Denied, _) => false, (Acquisition::Granted, ConsentSignal::Revoke) => false, (Acquisition::Granted, _) => true, (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, (Acquisition::RequiresSignal, _) => false, - }, - ) + } + }) .collect(); PermissionState { set } } @@ -757,7 +763,7 @@ impl PermissionMaps { /// and is what a request resolves to when no signal is present. #[must_use] pub fn baseline(&self, country: Option<&str>, region: Option<&str>) -> PermissionState { - self.resolve_with(country, region, |_| ConsentSignal::Neutral) + self.resolve_with(country, region, |_, _| ConsentSignal::Neutral) } /// Convenience over [`resolve_with`](Self::resolve_with) for a boolean @@ -769,7 +775,7 @@ impl PermissionMaps { country: Option<&str>, signal: impl Fn(Permission) -> bool, ) -> PermissionState { - self.resolve_with(country, None, |permission| { + self.resolve_with(country, None, |permission, _| { if signal(permission) { ConsentSignal::Grant } else { @@ -1434,7 +1440,7 @@ mod tests { "the top node grants storage, or this test proves nothing" ); assert!( - !PermissionMaps::floor_with(|_| ConsentSignal::Neutral) + !PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral) .is_set(Permission::StoreOnDevice), "the floor must not fall back to the top node" ); @@ -1626,7 +1632,7 @@ mod tests { .is_set(Permission::StoreOnDevice), "the US baseline should set necessary.operations.storage" ); - let revoked = maps.resolve_with(Some("US"), None, |p| { + let revoked = maps.resolve_with(Some("US"), None, |p, _| { if p == Permission::StoreOnDevice { ConsentSignal::Revoke } else { @@ -1708,7 +1714,7 @@ rules: // so it is not set even when a signal grants it. assert!( !maps - .resolve_with(Some("US"), Some("CA"), |_| ConsentSignal::Grant) + .resolve_with(Some("US"), Some("CA"), |_, _| ConsentSignal::Grant) .is_set(Permission::SelectBasicAds), "the permissions map denies advertising_marketing.first_party.contextual even when a signal grants it" ); From 5383a365108096d5183efd524fdc7baef4ecd045 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 15:40:54 +0100 Subject: [PATCH 081/133] Name the permission signal models in configuration The ordered list was core's own and an operator could not change it, so the order was real but nobody outside core could set it. A new [permission_signal] section names the models to run, in order. A model that is not on the list does not run, and there is no separate switch to turn one off, so a publisher who does not want to act on Global Privacy Control removes "gpc" from the list. Leaving the section out runs every model the build knows about, in the default order, so a signal is never quietly ignored because someone forgot to list it. An empty list runs none, leaving every permission at its country and region baseline. Split the single us-opt-out source into gpc, gpp-sale-opt-out and us-privacy. One source covering all three could not be pruned: removing Global Privacy Control would have taken the GPP and US Privacy opt-outs with it. Each still asks the policy whether its signal counts and what it takes away, so permissions.yaml keeps the meaning and the list keeps the membership. No behavior changes by default, because each returns Revoke or Neutral and never Grant, so three in sequence settle where one did. An unknown or repeated name is refused at startup by validate_selection, rather than silently matching nothing and stopping a model being honored. SOURCE_IDS is what configuration is checked against, and a test asserts it matches what all_sources actually returns, since the two drifting apart would let a name validate and then resolve to nothing. assemble_permissions keeps its signature and runs every model, which is the default. assemble_permissions_with takes the configured list, and the request path passes what the operator set. trusted-server.example.toml ships the full list commented, with a note to remove what the publisher does not want and a caution that dropping malformed-record means an unreadable record falls back to the baseline instead of failing closed. cargo fmt clean, clippy clean on all five targets, 2423 core tests, 2613 fastly, 40 axum, 43 cloudflare, 83 spin. --- crates/trusted-server-core/src/ec/consent.rs | 275 ++++++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 6 +- .../src/permission_signal/README.md | 15 +- crates/trusted-server-core/src/settings.rs | 145 +++++++++ trusted-server.example.toml | 37 +++ 5 files changed, 452 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 2ab0b747e..059b79357 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -87,8 +87,23 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { /// protectively rather than as the policy's declared default. #[must_use] pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { + assemble_permissions_with(consent, geo, &all_sources()) +} + +/// As [`assemble_permissions`], for a deployment that has named which signal +/// models run and in what order. +/// +/// A model missing from `sources` does not run, so a publisher removes one by +/// leaving it out rather than by configuring it off. An empty slice runs none +/// of them, which leaves every permission at its country and region baseline. +#[must_use] +pub fn assemble_permissions_with( + consent: &ConsentContext, + geo: GeoStatus<'_>, + sources: &[Arc], +) -> PermissionState { let maps = PermissionMaps::standard(); - let signal = permission_signal(consent, maps.signals()); + let signal = permission_signal(consent, maps.signals(), sources); match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { @@ -149,14 +164,30 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, + sources: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { - let sources = builtin_sources(); move |permission, baseline| { - crate::permission_signal::combine(&sources, permission, consent, signals, baseline) + crate::permission_signal::combine(sources, permission, consent, signals, baseline) } } -/// The signal models core supplies itself. +/// The identifiers of every signal model core supplies, in the default order. +/// +/// Configuration names sources from this list, and +/// [`PermissionSignalConfig::validate_selection`] rejects a name that is not in +/// it at startup rather than silently ignoring it. +/// +/// [`PermissionSignalConfig::validate_selection`]: +/// crate::settings::PermissionSignalConfig::validate_selection +pub const SOURCE_IDS: &[&str] = &[ + "gpc", + "gpp-sale-opt-out", + "us-privacy", + "malformed-record", + "tcf", +]; + +/// Every signal model core supplies, in the default order. /// /// These were decoded inline until the seam existed. They are the same rules, /// moved behind [`PermissionSignalSource`] so a further model can be added by a @@ -164,37 +195,110 @@ fn permission_signal<'a>( /// /// The order runs the signals needing no interaction before the ones following /// a prompt, so a visitor who arrives with an opt-out and then answers a prompt -/// has their answer applied. A deployment wanting the opposite puts the opt-out -/// source last. +/// has their answer applied. A deployment wanting the opposite reorders the +/// list in configuration. /// /// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource -fn builtin_sources() -> Vec> { +#[must_use] +pub fn all_sources() -> Vec> { vec![ - Arc::new(UsOptOutSource), + Arc::new(GpcSource), + Arc::new(GppSaleOptOutSource), + Arc::new(UsPrivacySource), Arc::new(MalformedRecordSource), Arc::new(TcfSource), ] } -/// A US-style opt-out, from Global Privacy Control, a GPP sale opt-out, or a -/// US Privacy string. +/// The sources a deployment named, in the order it named them. +/// +/// `None` means nothing was configured, which runs all of them in the default +/// order. That is deliberate: a publisher gets every model the build knows +/// about until they say otherwise, so a signal is never quietly ignored because +/// someone forgot to list it. +/// +/// A name that matches nothing is dropped here, having already been rejected at +/// startup by [`PermissionSignalConfig::validate_selection`]. +/// +/// [`PermissionSignalConfig::validate_selection`]: +/// crate::settings::PermissionSignalConfig::validate_selection +#[must_use] +pub fn sources_for(configured: Option<&[String]>) -> Vec> { + let all = all_sources(); + let Some(names) = configured else { + return all; + }; + names + .iter() + .filter_map(|name| { + all.iter() + .find(|source| source.id() == name.as_str()) + .map(Arc::clone) + }) + .collect() +} + +/// Whether one US-style opt-out takes `permission` away on this request. /// -/// Which of those count, and what an opt-out takes away, are both the policy's -/// decisions rather than this source's. It only reads whether one is present. -struct UsOptOutSource; +/// Shared by the three sources below, which differ only in the signal they +/// read. They are separate sources rather than one so that a publisher who does +/// not want to act on Global Privacy Control can leave that source out of the +/// configured list without also losing the GPP and US Privacy opt-outs. +/// +/// Which signals count at all, and what an opt-out takes away, remain the +/// policy's decisions. A source that the policy does not list stays silent even +/// when configuration names it, so removing it from `opt_out_sources` in +/// `permissions.yaml` and leaving it out of the list have the same effect. +fn opt_out_signal( + source: OptOutSource, + permission: Permission, + input: &SignalInput<'_>, +) -> ConsentSignal { + if input.policy.opt_out_sources().contains(&source) + && opt_out_present(input.consent, &[source]) + && input.policy.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + ConsentSignal::Neutral +} -impl PermissionSignalSource for UsOptOutSource { +/// The `Sec-GPC` request header, Global Privacy Control. +struct GpcSource; + +impl PermissionSignalSource for GpcSource { fn id(&self) -> &'static str { - "us-opt-out" + "gpc" } fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if opt_out_present(input.consent, input.policy.opt_out_sources()) - && input.policy.opt_out_revokes(permission) - { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral + opt_out_signal(OptOutSource::Gpc, permission, input) + } +} + +/// A GPP US sale opt-out. +struct GppSaleOptOutSource; + +impl PermissionSignalSource for GppSaleOptOutSource { + fn id(&self) -> &'static str { + "gpp-sale-opt-out" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + opt_out_signal(OptOutSource::GppSaleOptOut, permission, input) + } +} + +/// A US Privacy string sale opt-out. +struct UsPrivacySource; + +impl PermissionSignalSource for UsPrivacySource { + fn id(&self) -> &'static str { + "us-privacy" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + opt_out_signal(OptOutSource::UsPrivacyOptOut, permission, input) } } @@ -386,6 +490,135 @@ mod tests { ); } + // ------------------------------------------------------------------ + // Which models run. A publisher names them in [permission_signal] + // sources, and one left off the list does not run at all. + // ------------------------------------------------------------------ + + /// Every identifier except the one named, in the declared order. + fn every_source_except(excluded: &str) -> Vec { + SOURCE_IDS + .iter() + .filter(|id| **id != excluded) + .map(|id| (*id).to_owned()) + .collect() + } + + #[test] + fn the_declared_identifiers_match_the_models_that_run() { + // Configuration is validated against SOURCE_IDS and resolved against + // all_sources, so the two drifting apart would let a name validate and + // then match nothing, silently dropping a model. + let running: Vec<&str> = all_sources().iter().map(|source| source.id()).collect(); + assert_eq!( + running, SOURCE_IDS, + "SOURCE_IDS is what configuration is checked against, so it has to be what runs" + ); + } + + #[test] + fn naming_nothing_runs_every_model() { + let ids: Vec<&str> = sources_for(None).iter().map(|source| source.id()).collect(); + assert_eq!( + ids, SOURCE_IDS, + "a publisher who configures nothing acts on every signal the build knows, so \ + one is never ignored because they forgot to list it" + ); + } + + #[test] + fn the_configured_order_is_the_order_they_are_asked_in() { + let reversed: Vec = SOURCE_IDS.iter().rev().map(|id| (*id).to_owned()).collect(); + let ids: Vec = sources_for(Some(&reversed)) + .iter() + .map(|source| source.id().to_owned()) + .collect(); + assert_eq!( + ids, reversed, + "the list is the order, not merely the membership" + ); + } + + #[test] + fn a_model_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = + assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every model running, the header takes storage away" + ); + + let without_gpc = every_source_except("gpc"); + let pruned = assemble_permissions_with( + &consent, + GeoStatus::Located(&geo), + &sources_for(Some(&without_gpc)), + ); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the header then changes nothing" + ); + } + + #[test] + fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate sources rather than one. + let consent = ConsentContext { + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let without_gpc = every_source_except("gpc"); + let state = assemble_permissions_with( + &consent, + GeoStatus::Located(&geo), + &sources_for(Some(&without_gpc)), + ); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); + } + + #[test] + fn running_no_models_leaves_the_place_baseline() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + assert!( + state.is_set(Permission::StoreOnDevice), + "an empty list is a publisher acting on no signal at all, so only the country \ + and region rules apply" + ); + } + + #[test] + fn an_unknown_name_resolves_to_nothing_rather_than_a_wrong_model() { + // Startup validation rejects this first. The check here is that if one + // ever reached this far it would drop out rather than match by position. + let named = vec!["not-a-source".to_owned(), "tcf".to_owned()]; + let ids: Vec<&str> = sources_for(Some(&named)) + .iter() + .map(|source| source.id()) + .collect(); + assert_eq!(ids, vec!["tcf"]); + } + // ------------------------------------------------------------------ // Opt-out and prompt precedence. // diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 7bc4cdc30..91a5210a2 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -351,7 +351,11 @@ impl EcContext { // signals. Downstream consumers read the stored result via // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than // re-deriving it. - let permissions = consent::assemble_permissions(&consent, geo_status); + let permissions = consent::assemble_permissions_with( + &consent, + geo_status, + &consent::sources_for(settings.permission_signal.sources.as_deref()), + ); let storage_acquisition = consent::storage_acquisition(geo_status); // With no provider selected nothing may create or use an identifier, so // the gate is closed rather than open by default. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index ddffce6f3..78c4d4d30 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -43,10 +43,17 @@ prior value standing, which is different from refusing. The last source with an opinion decides, so the order is the policy. It is a deployment's to set, not this code's to assume. -Today the order is the order of the list handed to `combine`, which core -assembles itself. Naming the sources in operator configuration arrives with -the split into vendor crates, because a name in a configuration file is only -useful once a source can come from outside core. +```toml +[permission_signal] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "malformed-record", "tcf"] +``` + +A source not on the list does not run, and there is no separate switch. A +publisher who does not want to act on Global Privacy Control removes `"gpc"` +from the list. Leaving the section out entirely runs every model the build +knows about, in the default order, so a signal is never quietly ignored +because someone forgot to list it. An unknown or repeated name is refused at +startup, so a typo cannot silently stop a model being honored. The default order asks the signals needing no interaction first and the ones following a prompt after. Global Privacy Control withdraws personalisation on diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c33450da3..d8f1aa511 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -891,6 +891,68 @@ impl DeviceConfig { } } +/// Which permission signal models run, and in what order. +/// +/// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, +/// `[geo]` and `[device]` selectors, which each name one provider, signals +/// compose: a request can carry a TCF string and a Global Privacy Control +/// header at once and both have something to say. So this names a list, and +/// the order is the policy, because the last source with an opinion decides. +/// +/// See `crates/trusted-server-core/src/permission_signal/README.md`. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PermissionSignalConfig { + /// The models to run, in order, named by the identifiers in + /// [`SOURCE_IDS`](crate::ec::consent::SOURCE_IDS). + /// + /// Absent means every model the build knows about, in the default order. + /// A publisher who does not want to act on one removes it from the list; + /// there is no separate switch, because a model that is not listed does not + /// run. An empty list runs none of them, leaving every permission at its + /// country and region baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, +} + +impl PermissionSignalConfig { + /// Checks that every named model exists in this build and none is named + /// twice. + /// + /// Run at startup, so a typo is a refusal to boot rather than a signal that + /// silently stops being honored. + /// + /// # Errors + /// + /// - [`TrustedServerError::Configuration`] if a name is unknown or repeated. + pub fn validate_selection(&self) -> Result<(), Report> { + let Some(names) = self.sources.as_deref() else { + return Ok(()); + }; + for (position, name) in names.iter().enumerate() { + if !crate::ec::consent::SOURCE_IDS.contains(&name.as_str()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal source `{name}` is not available in this build. \ + Available sources are {}", + crate::ec::consent::SOURCE_IDS.join(", ") + ), + })); + } + if names[..position].contains(name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal source `{name}` is named more than once in \ + [permission_signal] sources. Each source runs once, at one place \ + in the order" + ), + })); + } + } + Ok(()) + } +} + /// Geo / IP intelligence configuration. /// /// Mapped from the `[geo]` TOML section. Selects which provider resolves a @@ -2993,6 +3055,10 @@ fn is_default_geo_config(value: &GeoConfig) -> bool { *value == GeoConfig::default() } +fn is_default_permission_signal_config(value: &PermissionSignalConfig) -> bool { + *value == PermissionSignalConfig::default() +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -3289,6 +3355,9 @@ pub struct Settings { #[serde(default, skip_serializing_if = "is_default_geo_config")] #[validate(nested)] pub geo: GeoConfig, + #[serde(default, skip_serializing_if = "is_default_permission_signal_config")] + #[validate(nested)] + pub permission_signal: PermissionSignalConfig, } impl Settings { @@ -3380,6 +3449,7 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; + settings.permission_signal.validate_selection()?; GeoConfig::validate_permission_policy()?; settings .geo @@ -8629,3 +8699,78 @@ formats = [{{ width = 300, height = 250 }}] } } } + +#[cfg(test)] +mod permission_signal_config_tests { + use super::*; + + fn config(sources: Option<&[&str]>) -> PermissionSignalConfig { + PermissionSignalConfig { + sources: sources.map(|names| names.iter().map(|name| (*name).to_owned()).collect()), + } + } + + #[test] + fn no_section_is_allowed_and_means_every_model() { + let config = PermissionSignalConfig::default(); + config + .validate_selection() + .expect("should accept a deployment that configures nothing"); + assert!( + config.sources.is_none(), + "absent rather than empty, because the two mean opposite things" + ); + } + + #[test] + fn every_declared_source_is_accepted() { + config(Some(crate::ec::consent::SOURCE_IDS)) + .validate_selection() + .expect("should accept the full list the example configuration ships"); + } + + #[test] + fn an_empty_list_is_accepted_as_acting_on_no_signal() { + config(Some(&[])) + .validate_selection() + .expect("should accept a publisher who acts on no signal at all"); + } + + #[test] + fn an_unknown_source_is_refused_at_startup() { + let error = config(Some(&["gpc", "gpq"])) + .validate_selection() + .expect_err("should refuse a name no model answers to"); + let message = format!("{error:?}"); + assert!( + message.contains("gpq"), + "the message should name the typo, so it can be found: {message}" + ); + assert!( + message.contains("gpc"), + "and list what was available: {message}" + ); + } + + #[test] + fn naming_a_source_twice_is_refused() { + let error = config(Some(&["gpc", "tcf", "gpc"])) + .validate_selection() + .expect_err("should refuse a repeat, which has no meaning in an ordered list"); + assert!(format!("{error:?}").contains("gpc")); + } + + #[test] + fn the_section_round_trips_through_toml() { + let parsed: PermissionSignalConfig = + toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); + assert_eq!( + parsed.sources.as_deref(), + Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), + "the order written is the order read, because the order is the policy" + ); + parsed + .validate_selection() + .expect("should accept two known sources"); + } +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index cdf817ecd..e52a24311 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -203,6 +203,43 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 +# Which permission signal models run, and in what order. +# +# Signals compose rather than select. A request can carry a TCF string and a +# Global Privacy Control header at once, and both have something to say, so +# this is a list where [ec], [geo] and [device] each name one provider. The +# order is the policy, because the last source with an opinion decides. +# +# Listed below is every model this build knows about, which is also exactly +# what runs when this section is absent. Remove the ones this publisher does +# not want to act on. A model that is not on the list does not run, and there +# is no separate switch to turn one off. An unknown or repeated name is +# refused at startup rather than quietly ignored. +# +# [permission_signal] +# sources = [ +# "gpc", # the Sec-GPC request header, Global Privacy Control +# "gpp-sale-opt-out", # a GPP US sale opt-out +# "us-privacy", # a US Privacy string sale opt-out +# "malformed-record", # a consent record that arrived and could not be read +# "tcf", # TCF v2 +# ] +# +# The three opt-outs are separate entries so that a publisher who does not act +# on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy +# opt-outs working. +# +# Removing "malformed-record" means a consent record that arrives unreadable +# falls back to the country and region baseline instead of failing closed. +# That is a real choice, so make it deliberately. +# +# The default order asks the signals needing no interaction before the ones +# that follow a prompt, so a visitor who arrives with an opt-out and then +# answers a prompt has their answer applied. Reorder the list to change that. +# +# The layering and what a model may consult are documented in +# crates/trusted-server-core/src/permission_signal/README.md. + # Proxy behavior and first-party asset routing. Kept active with defaults. [proxy] # Verify TLS certs when proxying to HTTPS origins (default true; false only for From 1c369bf7abb4797f8ea2b0ae88afeabe968a4e8f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 17:17:51 +0100 Subject: [PATCH 082/133] Stop treating an unreadable consent record as a model to choose malformed-record was never a signalling model. It is the flag ConsentContext::has_malformed_record sets when a raw TCF, GPP or US Privacy string arrives and will not decode, and the original code checked it as an early return ahead of TCF so an unreadable preference failed closed. Moving the inline chain behind the source trait made it look like a peer of gpc and tcf, and the configuration work then offered it in the list, so a publisher could remove it and silently turn off fail-closed handling that was never optional. It also read as incoherent next to the others: a publisher who removes tcf is saying which signal they act on, while removing malformed-record would be saying what happens when a signal they do act on arrives broken. Those are different questions. It is now applied ahead of the configured models and regardless of which are configured, and it is out of SOURCE_IDS entirely. That also fixes a regression the ordered rule introduced. With it sitting in the order, a TCF record that consents was asked after the unreadable GPP string and overwrote the refusal it caused, which the original early return never allowed. It overrides rather than holding a position, so one model arriving unreadable is not cured by another arriving readable. Two tests cover it, including that case. Startup still logs the selection, and now warns about every model left out rather than one, since a signal read from a request and then ignored is worth seeing when someone asks why it had no effect. cargo fmt clean, clippy clean on all five targets, 2429 core tests, 2619 fastly, 40 axum. --- crates/trusted-server-core/src/ec/consent.rs | 88 +++++++++++------ .../src/permission_signal/README.md | 26 ++++- crates/trusted-server-core/src/settings.rs | 95 +++++++++++++++++++ trusted-server.example.toml | 7 +- 4 files changed, 179 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 059b79357..7b1447d3b 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -167,6 +167,22 @@ fn permission_signal<'a>( sources: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { + // A record that arrived and could not be read fails closed, ahead of + // every configured model and regardless of which are configured. + // + // This is not a signalling model and is deliberately not in the + // configured list. A publisher chooses which signals to act on; they do + // not choose what happens when one of those signals arrives unreadable. + // An unreadable record is a preference someone expressed that cannot be + // read, which is different from no record at all, so it must not + // degrade to the no-signal baseline. + // + // It overrides rather than taking a place in the order because the + // ordered rule would otherwise let a readable record from one model + // overwrite the refusal caused by an unreadable one from another. + if consent.has_malformed_record() { + return ConsentSignal::Revoke; + } crate::permission_signal::combine(sources, permission, consent, signals, baseline) } } @@ -179,13 +195,7 @@ fn permission_signal<'a>( /// /// [`PermissionSignalConfig::validate_selection`]: /// crate::settings::PermissionSignalConfig::validate_selection -pub const SOURCE_IDS: &[&str] = &[ - "gpc", - "gpp-sale-opt-out", - "us-privacy", - "malformed-record", - "tcf", -]; +pub const SOURCE_IDS: &[&str] = &["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"]; /// Every signal model core supplies, in the default order. /// @@ -205,7 +215,6 @@ pub fn all_sources() -> Vec> { Arc::new(GpcSource), Arc::new(GppSaleOptOutSource), Arc::new(UsPrivacySource), - Arc::new(MalformedRecordSource), Arc::new(TcfSource), ] } @@ -302,26 +311,6 @@ impl PermissionSignalSource for UsPrivacySource { } } -/// A consent record that arrived and could not be read. -/// -/// Distinct from no record at all. An absent record is silence and leaves the -/// place baseline standing. A record that is present and malformed is a signal -/// we cannot trust, so it revokes rather than being ignored. -struct MalformedRecordSource; - -impl PermissionSignalSource for MalformedRecordSource { - fn id(&self) -> &'static str { - "malformed-record" - } - - fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if input.consent.has_malformed_record() { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral - } -} - /// TCF v2, when the policy says TCF answers for this deployment. /// /// The mapping from permission to purpose is the policy's, so this source @@ -490,6 +479,49 @@ mod tests { ); } + // ------------------------------------------------------------------ + // An unreadable record. Not a model a publisher lists, so it applies + // whatever they configured, and it is not subject to the ordering. + // ------------------------------------------------------------------ + + #[test] + fn an_unreadable_record_revokes_even_with_no_models_configured() { + let consent = ConsentContext { + raw_tc_string: Some("this is not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert!( + consent.has_malformed_record(), + "the fixture has to actually be unreadable for this to test anything" + ); + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a preference someone expressed that cannot be read must not degrade to the \ + no-signal baseline, and a publisher cannot configure that away" + ); + } + + #[test] + fn a_readable_record_does_not_overwrite_an_unreadable_one() { + // The regression the override exists to prevent: under the ordered + // rule alone, a TCF record that consents would be asked after the + // unreadable GPP string and would overwrite the refusal it caused. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert!(consent.has_malformed_record()); + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "one model arriving unreadable is not cured by another model arriving readable" + ); + } + // ------------------------------------------------------------------ // Which models run. A publisher names them in [permission_signal] // sources, and one left off the list does not run at all. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 78c4d4d30..4dd0fdfdf 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -45,7 +45,7 @@ deployment's to set, not this code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "malformed-record", "tcf"] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] ``` A source not on the list does not run, and there is no separate switch. A @@ -92,7 +92,23 @@ without changing a source. ## The models supplied -Core supplies the models it already understood: a US-style opt-out (Global -Privacy Control, a GPP sale opt-out, or a US Privacy string), a consent record -that arrived unreadable, and TCF v2. A deployment configuring nothing gets -those, in that order. +Core supplies the models it already understood: Global Privacy Control, a GPP +sale opt-out, a US Privacy string, and TCF v2. A deployment configuring +nothing gets those, in that order. + +The three opt-outs are separate rather than one so that a publisher who does +not act on Global Privacy Control can remove it and keep the other two. + +## What is not a source + +A consent record that arrives and cannot be read revokes, ahead of every +configured model and whichever ones are configured. That is error handling, +not a signalling model, so it is not in the list and cannot be removed. A +publisher chooses which signals to act on; they do not choose what happens +when one of those signals arrives unreadable. An unreadable record is a +preference someone expressed that could not be read, which is not the same as +no record at all, so it must not degrade to the no-signal baseline. + +It overrides rather than taking a place in the order, because the ordered rule +would otherwise let a readable record from one model overwrite the refusal +caused by an unreadable one from another. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index d8f1aa511..520225b3b 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -916,6 +916,60 @@ pub struct PermissionSignalConfig { } impl PermissionSignalConfig { + /// The models this configuration leaves out, in the declared order. + /// + /// Empty when nothing is configured, since that runs every model. + #[must_use] + pub fn omitted_sources(&self) -> Vec<&'static str> { + let Some(names) = self.sources.as_deref() else { + return Vec::new(); + }; + crate::ec::consent::SOURCE_IDS + .iter() + .copied() + .filter(|id| !names.iter().any(|name| name.as_str() == *id)) + .collect() + } + + /// Records the selection at startup, so which signals a deployment acts on + /// can be read from its log rather than inferred from its behavior. + /// + /// Any model left out is warned about, not merely noted. Removing one is a + /// deliberate choice a publisher is entitled to make, so it is not a + /// refusal, but a signal arriving on a request and then being ignored is + /// worth seeing in a log when someone asks why it had no effect. + pub fn log_selection(&self) { + let Some(names) = self.sources.as_deref() else { + log::info!( + "Permission signals: acting on every model, no [permission_signal] sources \ + configured" + ); + return; + }; + + if names.is_empty() { + log::info!( + "Permission signals: acting on no model, [permission_signal] sources is \ + empty, so every permission stays at its country and region baseline" + ); + } else { + log::info!( + "Permission signals: acting on {}, asked in that order", + names.join(", ") + ); + } + + let omitted = self.omitted_sources(); + if !omitted.is_empty() { + log::warn!( + "Permission signals: not acting on {}, which are not in [permission_signal] \ + sources. A signal this deployment does not act on is read from the request \ + and then ignored", + omitted.join(", ") + ); + } + } + /// Checks that every named model exists in this build and none is named /// twice. /// @@ -3450,6 +3504,7 @@ impl Settings { settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; settings.permission_signal.validate_selection()?; + settings.permission_signal.log_selection(); GeoConfig::validate_permission_policy()?; settings .geo @@ -8760,6 +8815,46 @@ mod permission_signal_config_tests { assert!(format!("{error:?}").contains("gpc")); } + #[test] + fn only_models_a_publisher_chooses_to_act_on_are_listed() { + // A malformed consent record fails closed whatever is configured, so it + // is deliberately not among the names. It is what happens when a signal + // arrives unreadable, not a signal anyone elects to honor. + assert!( + !crate::ec::consent::SOURCE_IDS.contains(&"malformed-record"), + "error handling must not be listed as though it were a signalling model" + ); + } + + #[test] + fn configuring_nothing_omits_nothing() { + assert!( + PermissionSignalConfig::default() + .omitted_sources() + .is_empty(), + "no section runs every model, so nothing is left out to report" + ); + } + + #[test] + fn a_dropped_model_is_reported_as_omitted() { + let omitted = config(Some(&["gpc", "tcf"])).omitted_sources(); + assert_eq!( + omitted, + vec!["gpp-sale-opt-out", "us-privacy"], + "what the log names has to be what was actually left out" + ); + } + + #[test] + fn an_empty_list_omits_every_model() { + assert_eq!( + config(Some(&[])).omitted_sources().len(), + crate::ec::consent::SOURCE_IDS.len(), + "acting on no signal leaves every model out, and the log says so" + ); + } + #[test] fn the_section_round_trips_through_toml() { let parsed: PermissionSignalConfig = diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e52a24311..e50e94b40 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -221,7 +221,6 @@ pull_sync_concurrency = 3 # "gpc", # the Sec-GPC request header, Global Privacy Control # "gpp-sale-opt-out", # a GPP US sale opt-out # "us-privacy", # a US Privacy string sale opt-out -# "malformed-record", # a consent record that arrived and could not be read # "tcf", # TCF v2 # ] # @@ -229,9 +228,9 @@ pull_sync_concurrency = 3 # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. # -# Removing "malformed-record" means a consent record that arrives unreadable -# falls back to the country and region baseline instead of failing closed. -# That is a real choice, so make it deliberately. +# A consent record that arrives and cannot be read revokes, whichever models +# are configured. That is error handling rather than a model to choose, so it +# is not in this list and cannot be removed. # # The default order asks the signals needing no interaction before the ones # that follow a prompt, so a visitor who arrives with an opt-out and then From 5d11c514cb34b479d1a11b1accb017482c7e4c6e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:14 +0100 Subject: [PATCH 083/133] Open the permission signal seam to providers outside core Core now supplies no signal model of its own. The seam that decides whether a permission is set takes providers from outside core, asks them in the order configuration gives, and applies the country and region rules to what they settle on. The four models that lived inline in ec/consent.rs are gone from core, and the trait they implemented is renamed PermissionSignalProvider and given what a provider for a scheme core has never heard of needs: the request as evidence, through RequestInfo, with a defaulted cookie accessor, alongside the decoded consent record for the schemes core's pipeline already decodes and caches. Three things move with the models. The TCF purpose to Data Use table leaves permissions.yaml and SignalPolicy, because which purpose grants which Data Use is the TCF scheme's own meaning rather than a deployment's policy, and a deployment that runs no TCF should carry no table of another scheme's numbers. Storage withdrawal becomes a provider's answer through a defaulted withdraws hook, scoped by core to the jurisdiction's storage baseline exactly as ec_storage_withdrawn scoped it, and is recorded on PermissionState so the finalize path reads it there. The hardcoded list of model identifiers leaves settings validation, because which names are valid is only known where the provider crates are linked, so an adapter's composition root now selects the providers once at startup through build_permission_signal_providers, which refuses an unknown or repeated name with a message naming what is available, and carries the shared list on RuntimeServices. One behavior of PR3 as pushed is not preserved, and it changed in the four commits this builds on rather than here. A US-style opt-out used to win over a consenting TCF record as a fixed rule in code. The providers are now asked in order and the last with an opinion decides. The default order asks the one signal with no interface of its own first, Global Privacy Control, and the three carrying a choice someone made through an interface after, so an answer given at a prompt amends the header the visitor arrived with, and a deployment wanting the opposite reorders the list. Every document that still stated the old rule is corrected here. --- config/permissions/vanilla.yaml | 80 +- crates/trusted-server-core/src/consent/mod.rs | 8 +- crates/trusted-server-core/src/ec/consent.rs | 834 ++++-------------- crates/trusted-server-core/src/ec/finalize.rs | 21 +- crates/trusted-server-core/src/ec/mod.rs | 56 +- crates/trusted-server-core/src/evidence.rs | 76 ++ .../src/permission_signal/README.md | 177 ++-- .../src/permission_signal/mod.rs | 571 +++++++++--- crates/trusted-server-core/src/permissions.rs | 153 ++-- .../trusted-server-core/src/platform/types.rs | 36 + crates/trusted-server-core/src/settings.rs | 231 +---- 11 files changed, 1047 insertions(+), 1196 deletions(-) diff --git a/config/permissions/vanilla.yaml b/config/permissions/vanilla.yaml index 2c8fc2fcc..dfb0452bd 100644 --- a/config/permissions/vanilla.yaml +++ b/config/permissions/vanilla.yaml @@ -497,59 +497,43 @@ rules: group: us-opt-out jurisdiction: us-state -# How each session signal maps onto Data Uses. The permission engine reads this, -# so no signal-to-permission policy lives in the code. For each Data Use a signal -# produces a grant or a revoke, which the resolver then applies against the group -# baseline above (a grant sets a `requires_signal` Data Use, a revoke drops a -# `granted` one, and `denied` always wins so no signal can set it). +# What each session signal is allowed to do to the Data Uses, for the +# decisions that are a deployment's policy rather than a scheme's own meaning. +# For each Data Use a signal produces a grant or a revoke, which the resolver +# then applies against the group baseline above (a grant sets a +# `requires_signal` Data Use, a revoke drops a `granted` one, and `denied` +# always wins so no signal can set it). +# +# What a scheme's own signal means is not here. Which TCF purpose grants which +# Data Use is the TCF scheme's knowledge and lives in the TCF permission signal +# provider crate, crates/permission-signal/tcf, so a deployment that runs no +# TCF carries no table of another scheme's numbers. Which schemes run at all, +# and in what order, is [permission_signal] sources in trusted-server.toml. signals: # A present TCF v2 record (a standalone TC string, or the EU TCF section of a - # GPP string). With authoritative true, each listed TCF purpose grants the - # Data Use it maps to when the record consents to that purpose, and revokes it - # otherwise; with authoritative false the record is ignored. The flag governs - # only the TCF record's own grants and revokes: an opt-out signal below always - # suppresses the Data Uses it revokes, even alongside a consenting TCF record, - # because an explicit opt-out is never overridden by another signal. This is - # the interim home for the purpose to Data Use mapping, and a purpose may - # grant one Data Use or a list of them. Once the IAB Privacy Taxonomy tcf - # column is finalized (fideslang) that becomes the single source and this - # block is dropped. + # GPP string). With authoritative true, the record's consent to a purpose + # grants the Data Uses the TCF provider maps to that purpose, and its refusal + # revokes them, whereas with authoritative false the record is ignored. + # The flag governs only whether the record answers. Whether its answer + # stands over an opt-out signal below, or the opt-out over it, is the order + # the providers are asked in, [permission_signal] sources in + # trusted-server.toml, where the last provider with an opinion decides. The + # default order asks Global Privacy Control first, being a browser setting + # with no interface of its own, and the schemes carrying a choice someone + # made through an interface after. tcf: authoritative: true - purposes: - 1: necessary.operations.storage - 2: - - advertising_marketing.first_party.contextual - - advertising_marketing.frequency_capping - - advertising_marketing.negative_targeting - 3: advertising_marketing.profiling - 4: - - advertising_marketing.first_party.targeted - - advertising_marketing.third_party.targeted - 5: advertising_marketing.personalize.profiling - 6: - - advertising_marketing.personalize.content - - advertising_marketing.personalize.system - - functional.personalization - 7: - - analytics.ad_reporting.measure_ad_performance - - analytics.ad_reporting.ad_delivery_and_targeting - - analytics.ad_reporting.ad_viewability - 8: analytics.ad_reporting.content_performance - 9: - - analytics.ad_reporting.market_research - - analytics.ad_reporting.campaign_insights - 10: necessary.operations.improve - 11: select-basic-content # US-style opt-out of sale or sharing. It applies when any listed source is - # set, and it suppresses the Data Uses it revokes even when a TCF record - # consents to them. The list below models the US state opt-out scope, being - # sale, sharing, targeted advertising, and device storage as the - # conservative reading, so an opted-out visitor gets no Edge Cookie - # written and no identifier shared, whilst contextual advertising and - # measurement continue after an opt-out. A deployer can widen the list, or write `revokes: all` to drop - # every granted Data Use. An opt-out suppresses use for the request; it is - # never a destructive withdrawal of an already-issued identifier. + # set, and its provider revokes the Data Uses listed here. Whether that + # revoke stands when a TCF record consents to the same Data Use depends on + # the provider order, see above. The list below models the US state opt-out + # scope, being sale, sharing, targeted advertising, and device storage as + # the conservative reading, so an opted-out visitor gets no Edge Cookie + # written and no identifier shared, while contextual advertising and + # measurement continue after an opt-out. A deployer can widen the list, or + # write `revokes: all` to drop every granted Data Use. An opt-out suppresses + # use for the request and is never a destructive withdrawal of an + # already-issued identifier. us_opt_out: sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] revokes: diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index df98bd7aa..a1ca695c4 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -345,8 +345,14 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b } /// Returns the effective decoded TCF consent for enforcement decisions. +/// +/// A standalone TC string wins, and the EU TCF section of a GPP string stands +/// in when there is none. Public because the TCF permission signal provider +/// lives outside core and reads the record this pipeline produced, rather than +/// decoding the cookie a second time and disagreeing with every other reader +/// of the same request about expiry and the cached record. #[must_use] -pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 7b1447d3b..e0a6ee4e0 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -2,20 +2,26 @@ //! //! The Edge Cookie provider advertises the [`Permission`]s its data use //! requires. [`assemble_permissions`] resolves which permissions are set for a -//! request, from its session signals and the country it maps to, and the -//! context construction gates the provider on that state. The EC permission -//! decision lives here, in the EC subsystem, and nowhere else, so callers -//! route every EC permission check through this module rather than +//! request, from the country it maps to and what the signal providers say, +//! and the context construction gates the provider on that state. The EC +//! permission decision lives here, in the EC subsystem, and nowhere else, so +//! callers route every EC permission check through this module rather than //! re-deriving one. +//! +//! No scheme is decoded here. Which signals count, and what each says about +//! a permission, is answered by the [`PermissionSignalProvider`]s the adapter +//! hands in, every one of which is a crate outside core. This module asks +//! them in order and applies the country and region rules to what they +//! settle on. -use crate::consent::ConsentContext; -use crate::consent::jurisdiction::Jurisdiction; use std::sync::Arc; -use crate::permission_signal::{PermissionSignalSource, SignalInput}; +use crate::consent::ConsentContext; +use crate::consent::jurisdiction::Jurisdiction; +use crate::evidence::RequestInfo; +use crate::permission_signal::{self, PermissionSignalProvider}; use crate::permissions::{ - Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, - SignalPolicy, + Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionState, SignalPolicy, }; use crate::platform::GeoInfo; @@ -76,35 +82,34 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { } /// Assembles the permission state for a request: the place baseline from the -/// tree in `permissions.yaml`, augmented by the session's signals. +/// tree in `permissions.yaml`, amended by what the signal providers say. /// -/// Permissions exist without a consent model. With no signal present the result -/// is simply the baseline for the request's country and region. When the geo -/// provider resolves no location, or a country/region that has no rule, the -/// policy's top node applies, and the top node's `group` is required so one is -/// always available. A failed lookup ([`GeoStatus::Failed`]) instead resolves -/// every permission to the requires-signal floor, so an outage is handled -/// protectively rather than as the policy's declared default. -#[must_use] -pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { - assemble_permissions_with(consent, geo, &all_sources()) -} - -/// As [`assemble_permissions`], for a deployment that has named which signal -/// models run and in what order. +/// Permissions exist without a consent model. With no provider having an +/// opinion the result is simply the baseline for the request's country and +/// region. When the geo provider resolves no location, or a country/region +/// that has no rule, the policy's top node applies, and the top node's `group` +/// is required so one is always available. A failed lookup +/// ([`GeoStatus::Failed`]) instead resolves every permission to the +/// requires-signal floor, so an outage is handled protectively rather than as +/// the policy's declared default. /// -/// A model missing from `sources` does not run, so a publisher removes one by -/// leaving it out rather than by configuring it off. An empty slice runs none -/// of them, which leaves every permission at its country and region baseline. +/// `providers` are the signal providers the adapter selected, in the order +/// they run. A scheme missing from the list does not run, so a publisher +/// removes one by leaving it out rather than by configuring it off, and an +/// empty slice runs none of them, which leaves every permission at its +/// country and region baseline. The same providers answer whether storage +/// was explicitly withdrawn, recorded on the state and read through +/// [`PermissionState::storage_withdrawn`]. #[must_use] -pub fn assemble_permissions_with( +pub fn assemble_permissions( consent: &ConsentContext, + evidence: &dyn RequestInfo, geo: GeoStatus<'_>, - sources: &[Arc], + providers: &[Arc], ) -> PermissionState { let maps = PermissionMaps::standard(); - let signal = permission_signal(consent, maps.signals(), sources); - match geo { + let signal = permission_signal(consent, evidence, maps.signals(), providers); + let state = match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { let info = geo.info(); @@ -114,7 +119,16 @@ pub fn assemble_permissions_with( signal, ) } - } + }; + let withdrawn = permission_signal::withdrawn( + providers, + Permission::StoreOnDevice, + consent, + evidence, + maps.signals(), + storage_acquisition(geo), + ); + state.with_storage_withdrawn(withdrawn) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -142,19 +156,17 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { } } -/// Maps a consent context to a [`ConsentSignal`] for each permission, applying -/// the [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// Maps a request to a [`ConsentSignal`] for each permission, applying the +/// [`SignalPolicy`] the permission model parsed from `permissions.yaml`. /// -/// This is the only place the EC subsystem reads consent signals. The policy, -/// not this function, decides which sources are authoritative, which TCF purpose -/// maps to which Data Use, and what a US-style opt-out revokes. This function -/// only decodes the request and applies that policy, so no signal-to-permission -/// policy lives in the code. +/// This is the only place the EC subsystem consults the signal providers. The +/// policy, not this function, decides which schemes are authoritative and what +/// a US-style opt-out revokes, and each provider decides what its own scheme +/// says. This function only hands each provider the request, in order, so no +/// signal-to-permission rule lives in core. /// -/// The models it asks are the ones core supplies (see [`builtin_sources`]), -/// each of which amends what the ones before it settled on. The order is the -/// policy, and [`combine`] documents why. This function only assembles the -/// list and hands each source the request. +/// Each provider amends what the ones before it settled on. The order is the +/// policy, and [`combine`] documents why. /// /// Whether an amendment changes anything is then decided by the country/region /// map, which drops a `granted` baseline on a `Revoke` and has nothing to drop @@ -163,259 +175,81 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { /// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, signals: &'a SignalPolicy, - sources: &'a [Arc], + providers: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { // A record that arrived and could not be read fails closed, ahead of - // every configured model and regardless of which are configured. + // every configured provider and regardless of which are configured. // - // This is not a signalling model and is deliberately not in the - // configured list. A publisher chooses which signals to act on; they do - // not choose what happens when one of those signals arrives unreadable. - // An unreadable record is a preference someone expressed that cannot be + // This is not a signaling scheme and is deliberately not in the + // configured list. A publisher chooses which signals to act on, but + // not what happens when one of those signals arrives unreadable. An + // unreadable record is a preference someone expressed that cannot be // read, which is different from no record at all, so it must not // degrade to the no-signal baseline. // // It overrides rather than taking a place in the order because the - // ordered rule would otherwise let a readable record from one model + // ordered rule would otherwise let a readable record from one scheme // overwrite the refusal caused by an unreadable one from another. if consent.has_malformed_record() { return ConsentSignal::Revoke; } - crate::permission_signal::combine(sources, permission, consent, signals, baseline) - } -} - -/// The identifiers of every signal model core supplies, in the default order. -/// -/// Configuration names sources from this list, and -/// [`PermissionSignalConfig::validate_selection`] rejects a name that is not in -/// it at startup rather than silently ignoring it. -/// -/// [`PermissionSignalConfig::validate_selection`]: -/// crate::settings::PermissionSignalConfig::validate_selection -pub const SOURCE_IDS: &[&str] = &["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"]; - -/// Every signal model core supplies, in the default order. -/// -/// These were decoded inline until the seam existed. They are the same rules, -/// moved behind [`PermissionSignalSource`] so a further model can be added by a -/// module instead of by editing core. -/// -/// The order runs the signals needing no interaction before the ones following -/// a prompt, so a visitor who arrives with an opt-out and then answers a prompt -/// has their answer applied. A deployment wanting the opposite reorders the -/// list in configuration. -/// -/// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource -#[must_use] -pub fn all_sources() -> Vec> { - vec![ - Arc::new(GpcSource), - Arc::new(GppSaleOptOutSource), - Arc::new(UsPrivacySource), - Arc::new(TcfSource), - ] -} - -/// The sources a deployment named, in the order it named them. -/// -/// `None` means nothing was configured, which runs all of them in the default -/// order. That is deliberate: a publisher gets every model the build knows -/// about until they say otherwise, so a signal is never quietly ignored because -/// someone forgot to list it. -/// -/// A name that matches nothing is dropped here, having already been rejected at -/// startup by [`PermissionSignalConfig::validate_selection`]. -/// -/// [`PermissionSignalConfig::validate_selection`]: -/// crate::settings::PermissionSignalConfig::validate_selection -#[must_use] -pub fn sources_for(configured: Option<&[String]>) -> Vec> { - let all = all_sources(); - let Some(names) = configured else { - return all; - }; - names - .iter() - .filter_map(|name| { - all.iter() - .find(|source| source.id() == name.as_str()) - .map(Arc::clone) - }) - .collect() -} - -/// Whether one US-style opt-out takes `permission` away on this request. -/// -/// Shared by the three sources below, which differ only in the signal they -/// read. They are separate sources rather than one so that a publisher who does -/// not want to act on Global Privacy Control can leave that source out of the -/// configured list without also losing the GPP and US Privacy opt-outs. -/// -/// Which signals count at all, and what an opt-out takes away, remain the -/// policy's decisions. A source that the policy does not list stays silent even -/// when configuration names it, so removing it from `opt_out_sources` in -/// `permissions.yaml` and leaving it out of the list have the same effect. -fn opt_out_signal( - source: OptOutSource, - permission: Permission, - input: &SignalInput<'_>, -) -> ConsentSignal { - if input.policy.opt_out_sources().contains(&source) - && opt_out_present(input.consent, &[source]) - && input.policy.opt_out_revokes(permission) - { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral -} - -/// The `Sec-GPC` request header, Global Privacy Control. -struct GpcSource; - -impl PermissionSignalSource for GpcSource { - fn id(&self) -> &'static str { - "gpc" - } - - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::Gpc, permission, input) + permission_signal::combine(providers, permission, consent, evidence, signals, baseline) } } -/// A GPP US sale opt-out. -struct GppSaleOptOutSource; +#[cfg(test)] +mod tests { + use http::HeaderMap; -impl PermissionSignalSource for GppSaleOptOutSource { - fn id(&self) -> &'static str { - "gpp-sale-opt-out" - } + use super::*; + use crate::evidence::OwnedRequestInfo; + use crate::permission_signal::SignalInput; + use crate::test_support::tests::create_test_settings; - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::GppSaleOptOut, permission, input) - } -} + /// A provider that grants every permission, standing in for a scheme that + /// answered a prompt, so the assembly rules can be exercised without any + /// real scheme in core. + struct Granting; -/// A US Privacy string sale opt-out. -struct UsPrivacySource; + impl PermissionSignalProvider for Granting { + fn id(&self) -> &'static str { + "granting" + } -impl PermissionSignalSource for UsPrivacySource { - fn id(&self) -> &'static str { - "us-privacy" + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Grant + } } - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::UsPrivacyOptOut, permission, input) + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) } -} -/// TCF v2, when the policy says TCF answers for this deployment. -/// -/// The mapping from permission to purpose is the policy's, so this source -/// decodes and does not interpret. A permission no purpose maps to gets -/// silence, not a refusal. -struct TcfSource; - -impl PermissionSignalSource for TcfSource { - fn id(&self) -> &'static str { - "tcf" + fn granting() -> Vec> { + vec![Arc::new(Granting)] } - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if !input.policy.tcf_authoritative() { - return ConsentSignal::Neutral; - } - let Some(tcf) = crate::consent::effective_tcf(input.consent) else { - return ConsentSignal::Neutral; - }; - match input.policy.tcf_purpose(permission) { - Some(purpose) => { - if tcf.has_purpose_consent(usize::from(purpose)) { - ConsentSignal::Grant - } else { - // A purpose the visitor did not consent to is a refusal, - // not silence. Reading it as silence would leave the place - // baseline standing and grant what they declined. - ConsentSignal::Revoke - } - } - None => ConsentSignal::Neutral, - } + fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + providers: &[Arc], + ) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, providers) } -} - -/// Whether the request carries any of the `sources` a US-style opt-out is -/// declared to use. Decoding only, so the policy (not this function) decides -/// which sources count and what the opt-out revokes. -fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { - sources.iter().any(|source| match source { - OptOutSource::Gpc => consent.gpc, - OptOutSource::GppSaleOptOut => { - consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) - } - OptOutSource::UsPrivacyOptOut => consent - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), - }) -} -/// Reports whether the request carries an explicit signal withdrawing Edge -/// Cookie storage, rather than merely lacking the permission. -/// -/// This separates an affirmative withdrawal (which expires the browser cookie -/// and writes the authoritative identity-graph tombstone) from suppression, -/// where the permission is simply not set for this request (which strips EC -/// response headers but must not destroy an already-issued identifier, or a -/// returning user would be permanently withdrawn before they ever get to -/// consent). -/// -/// Only a TCF record refusing storage (Purpose 1) withdraws, and only where -/// the jurisdiction's storage baseline is not `granted`: under a -/// `requires_signal` baseline the refusal is the visitor declining the very -/// signal storage depends on, while under a `granted` baseline storage never -/// depended on the record, so the refusal suppresses use without destroying -/// the identifier. US-style opt-outs (GPC, a GPP sale opt-out, or a US -/// Privacy opt-out) suppress the permissions the policy revokes but are -/// never destructive, and no signal at all is not a withdrawal. -#[must_use] -pub fn ec_storage_withdrawn(consent: &ConsentContext, storage_baseline: Acquisition) -> bool { - if let Some(tcf) = crate::consent::effective_tcf(consent) { - return !tcf.has_storage_consent() && !matches!(storage_baseline, Acquisition::Granted); - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::consent::TcfConsent; - use crate::test_support::tests::create_test_settings; - - /// Builds a minimal decoded TCF record consenting to the given 1-indexed - /// purposes, with everything else refused. - fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { - let mut purpose_consents = vec![false; 24]; - for &purpose in consented { - purpose_consents[purpose - 1] = true; - } - TcfConsent { - version: 2, - cmp_id: 0, - cmp_version: 0, - consent_screen: 0, - consent_language: "EN".to_owned(), - vendor_list_version: 0, - tcf_policy_version: 2, - created_ds: 0, - last_updated_ds: 0, - purpose_consents, - purpose_legitimate_interests: vec![false; 24], - vendor_consents: Vec::new(), - vendor_legitimate_interests: Vec::new(), - special_feature_opt_ins: vec![false; 12], + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, } } @@ -424,37 +258,24 @@ mod tests { let settings = create_test_settings(); // The test settings select the HMAC provider, which requires // necessary.operations.storage. The policy's top node resolves storage - // as requires-signal, so with no signal the permission is not set and - // the provider's requirement is not met. + // as requires-signal, so with no provider granting it the permission is + // not set and the provider's requirement is not met. let provider = crate::ec::provider::build_provider(&settings.ec, None, None) .expect("should build the configured provider") .expect("should select the hmac provider"); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); assert!( !state.all_set(provider.required_permissions()), "the requires-signal default should not satisfy the HMAC provider without a signal" ); } - fn us_ca_geo() -> GeoInfo { - GeoInfo { - city: String::new(), - country: "US".to_owned(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: Some("CA".to_owned()), - asn: None, - } - } - #[test] fn no_signal_uses_the_us_opt_out_baseline() { // US/CA maps to the us-opt-out group, where every purpose is granted // without a signal, so EC identity and bidstream EIDs are both permitted. let geo = us_ca_geo(); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)); + let state = assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]); assert!( state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), @@ -463,29 +284,31 @@ mod tests { } #[test] - fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { - // A US-style opt-out drops a granted baseline with no jurisdiction match: - // the map granted these purposes, and GPC revokes them. - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + fn a_grant_sets_a_requires_signal_permission() { + // The top node requires a signal for storage, and a provider granting + // it is what a signal arriving looks like from here. + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &granting(), + ); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + state.is_set(Permission::StoreOnDevice), + "a provider granting storage should set it under a requires-signal baseline" + ); + assert!( + !state.storage_withdrawn(), + "and a grant is the opposite of a withdrawal" ); } // ------------------------------------------------------------------ - // An unreadable record. Not a model a publisher lists, so it applies + // An unreadable record. Not a scheme a publisher lists, so it applies // whatever they configured, and it is not subject to the ordering. // ------------------------------------------------------------------ #[test] - fn an_unreadable_record_revokes_even_with_no_models_configured() { + fn an_unreadable_record_revokes_even_with_no_providers_configured() { let consent = ConsentContext { raw_tc_string: Some("this is not a TC string".to_owned()), ..ConsentContext::default() @@ -495,7 +318,7 @@ mod tests { "the fixture has to actually be unreadable for this to test anything" ); let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + let state = assembled(&consent, GeoStatus::Located(&geo), &[]); assert!( !state.is_set(Permission::StoreOnDevice), "a preference someone expressed that cannot be read must not degrade to the \ @@ -506,392 +329,71 @@ mod tests { #[test] fn a_readable_record_does_not_overwrite_an_unreadable_one() { // The regression the override exists to prevent: under the ordered - // rule alone, a TCF record that consents would be asked after the + // rule alone, a provider that grants would be asked after the // unreadable GPP string and would overwrite the refusal it caused. let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), raw_gpp_string: Some("this is not a GPP string".to_owned()), ..ConsentContext::default() }; - assert!(consent.has_malformed_record()); - let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); - assert!( - !state.is_set(Permission::StoreOnDevice), - "one model arriving unreadable is not cured by another model arriving readable" - ); - } - - // ------------------------------------------------------------------ - // Which models run. A publisher names them in [permission_signal] - // sources, and one left off the list does not run at all. - // ------------------------------------------------------------------ - - /// Every identifier except the one named, in the declared order. - fn every_source_except(excluded: &str) -> Vec { - SOURCE_IDS - .iter() - .filter(|id| **id != excluded) - .map(|id| (*id).to_owned()) - .collect() - } - - #[test] - fn the_declared_identifiers_match_the_models_that_run() { - // Configuration is validated against SOURCE_IDS and resolved against - // all_sources, so the two drifting apart would let a name validate and - // then match nothing, silently dropping a model. - let running: Vec<&str> = all_sources().iter().map(|source| source.id()).collect(); - assert_eq!( - running, SOURCE_IDS, - "SOURCE_IDS is what configuration is checked against, so it has to be what runs" - ); - } - - #[test] - fn naming_nothing_runs_every_model() { - let ids: Vec<&str> = sources_for(None).iter().map(|source| source.id()).collect(); - assert_eq!( - ids, SOURCE_IDS, - "a publisher who configures nothing acts on every signal the build knows, so \ - one is never ignored because they forgot to list it" - ); - } - - #[test] - fn the_configured_order_is_the_order_they_are_asked_in() { - let reversed: Vec = SOURCE_IDS.iter().rev().map(|id| (*id).to_owned()).collect(); - let ids: Vec = sources_for(Some(&reversed)) - .iter() - .map(|source| source.id().to_owned()) - .collect(); - assert_eq!( - ids, reversed, - "the list is the order, not merely the membership" - ); - } - - #[test] - fn a_model_left_off_the_list_does_not_run() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - - let everything = - assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); - assert!( - !everything.is_set(Permission::StoreOnDevice), - "with every model running, the header takes storage away" - ); - - let without_gpc = every_source_except("gpc"); - let pruned = assemble_permissions_with( - &consent, - GeoStatus::Located(&geo), - &sources_for(Some(&without_gpc)), - ); assert!( - pruned.is_set(Permission::StoreOnDevice), - "a publisher who does not want to act on Global Privacy Control removes it from \ - the list, and the header then changes nothing" + consent.has_malformed_record(), + "the fixture has to actually be unreadable for this to test anything" ); - } - - #[test] - fn removing_one_opt_out_leaves_the_others_working() { - // The reason the three opt-outs are separate sources rather than one. - let consent = ConsentContext { - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; let geo = us_ca_geo(); - let without_gpc = every_source_except("gpc"); - let state = assemble_permissions_with( - &consent, - GeoStatus::Located(&geo), - &sources_for(Some(&without_gpc)), - ); + let state = assembled(&consent, GeoStatus::Located(&geo), &granting()); assert!( !state.is_set(Permission::StoreOnDevice), - "dropping Global Privacy Control must not drop the US Privacy opt-out with it" - ); - } - - #[test] - fn running_no_models_leaves_the_place_baseline() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); - assert!( - state.is_set(Permission::StoreOnDevice), - "an empty list is a publisher acting on no signal at all, so only the country \ - and region rules apply" + "one scheme arriving unreadable is not cured by another scheme granting" ); } #[test] - fn an_unknown_name_resolves_to_nothing_rather_than_a_wrong_model() { - // Startup validation rejects this first. The check here is that if one - // ever reached this far it would drop out rather than match by position. - let named = vec!["not-a-source".to_owned(), "tcf".to_owned()]; - let ids: Vec<&str> = sources_for(Some(&named)) - .iter() - .map(|source| source.id()) - .collect(); - assert_eq!(ids, vec!["tcf"]); - } - - // ------------------------------------------------------------------ - // Opt-out and prompt precedence. - // - // These replace an earlier set asserting the opposite, that an opt-out - // suppressed storage and sharing whatever else the request carried. The - // sources are now asked in order and each amends what the ones before it - // settled, so a later source can amend an opt-out. - // - // The default order asks the signals needing no interaction first and the - // ones following a prompt after, which is why a visitor who arrives with - // an opt-out and then answers a prompt has their answer applied. A - // deployment wanting the opposite puts the opt-out source last. - // - // The layering, the configuration and what a source may consult are in - // crates/trusted-server-core/src/permission_signal/README.md. - // ------------------------------------------------------------------ - - #[test] - fn a_prompt_answer_applies_over_a_gpc_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpc: true, + fn a_malformed_gpp_or_us_privacy_record_is_detected() { + // Each undecodable record form has to be detected, or the override + // above would not fire for it. + let gpp = ConsentContext { + raw_gpp_string: Some("not-a-gpp-string".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with GPC set, and under the \ - default order the answer they gave is applied over the header they sent" - ); - } - - #[test] - fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), + let usp = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with a US Privacy opt-out, and under the \ - default order the answer they gave amends the signal they sent" - ); - } - - #[test] - fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpp: Some(crate::consent::types::GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with a GPP sale opt-out, and under the \ - default order the answer they gave amends the signal they sent" + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" ); - } - - #[test] - fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { - let consent = ConsentContext { - gpc: true, - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::No, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice), - "any one opt-out source should suppress, whatever the others say" - ); - } - - // ------------------------------------------------------------------ - // Withdrawal scoping: only a TCF storage refusal withdraws, and only - // where the baseline did not grant storage outright. Opt-outs suppress - // use but never destroy an already-issued identifier. - // ------------------------------------------------------------------ - - #[test] - fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; - assert!( - ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "refusing the signal storage depends on should withdraw" - ); - } - - #[test] - fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::Granted), - "storage never depended on the record here, so refusal suppresses without destroying" - ); - } - - #[test] - fn tcf_storage_consent_is_not_a_withdrawal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1])), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "a consenting record is not a withdrawal" - ); - } - - #[test] - fn gpc_alone_never_withdraws() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::Granted) - && !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "GPC suppresses use for the request but never destroys the identifier" - ); - } - - #[test] - fn us_style_opt_outs_never_withdraw() { - let consent = ConsentContext { - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - gpp: Some(crate::consent::types::GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "sale opt-outs suppress use but never destroy the identifier" - ); - } - - #[test] - fn no_signal_is_not_a_withdrawal() { - assert!( - !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), - "absence of a signal must never destroy an identifier" - ); - } - - #[test] - fn a_malformed_record_is_not_a_withdrawal() { - let consent = ConsentContext { - raw_tc_string: Some("not-a-tc-string".to_owned()), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "an unreadable record fails closed (suppression), not destructively" + !assembled(&usp, GeoStatus::Located(&geo), &granting()) + .is_set(Permission::StoreOnDevice), + "an unreadable US Privacy string blocks the granted baseline like any other record" ); } - // ------------------------------------------------------------------ - // Malformed-but-present records block baseline grants (fail closed) - // instead of degrading to the no-signal baseline. - // ------------------------------------------------------------------ - #[test] - fn a_malformed_tcf_record_blocks_baseline_grants() { + fn an_unreadable_record_is_not_a_withdrawal() { let consent = ConsentContext { raw_tc_string: Some("not-a-tc-string".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - !state.is_set(Permission::StoreOnDevice), - "an unreadable record should block the granted baseline, not vanish" - ); - } - - #[test] - fn a_malformed_gpp_or_us_privacy_record_is_detected() { - let gpp = ConsentContext { - raw_gpp_string: Some("not-a-gpp-string".to_owned()), - ..ConsentContext::default() - }; - let usp = ConsentContext { - raw_us_privacy: Some("bogus".to_owned()), - ..ConsentContext::default() - }; + let state = assembled(&consent, GeoStatus::NoLocation, &granting()); assert!( - gpp.has_malformed_record() && usp.has_malformed_record(), - "each undecodable record form should be detected" + !state.storage_withdrawn(), + "an unreadable record fails closed by suppression, never destructively" ); } #[test] - fn an_expired_tcf_record_is_not_treated_as_malformed() { - let consent = ConsentContext { - raw_tc_string: Some("CPc-old-string".to_owned()), - expired: true, - ..ConsentContext::default() - }; + fn running_no_providers_leaves_the_place_baseline() { let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + let state = assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]); assert!( state.is_set(Permission::StoreOnDevice), - "expiry is its own explicit state, deliberately distinct from malformed" + "an empty list is a publisher acting on no signal at all, so only the country \ + and region rules apply" ); + assert!(!state.storage_withdrawn(), "and nothing can have withdrawn"); } // ------------------------------------------------------------------ @@ -907,11 +409,11 @@ mod tests { // other, so nothing is set without a signal. let geo = us_ca_geo(); assert!( - assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)) + assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]) .is_set(Permission::StoreOnDevice), "the located baseline must grant storage, or this test proves nothing" ); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Failed); + let state = assembled(&ConsentContext::default(), GeoStatus::Failed, &[]); assert!( !state.is_set(Permission::StoreOnDevice), "a lookup failure must not fall back to any node of the policy tree" @@ -928,7 +430,7 @@ mod tests { // The shipped policy's top node is the gdpr-eu group, which requires a // signal for storage, so an unplaced visitor gets no identifier until // one arrives. - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); assert!( !state.is_set(Permission::StoreOnDevice), "the top node requires a signal for storage" @@ -953,36 +455,4 @@ mod tests { "a failed lookup must not adopt the policy's declared jurisdiction" ); } - - #[test] - fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { - // A TCF record now grants or revokes every one of the eleven mapped - // purposes, not only Purpose 1 and Purpose 4. Consent to all purposes - // except Purpose 7 (measure ad performance), in a US opt-out state where - // the baseline granted them all, so a revoke is observable as a drop. - let consented: Vec = (1..=11).filter(|&p| p != 7).collect(); - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&consented)), - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - - // Purpose 2 is now resolved (it was neutral before), so consent sets it. - assert!( - state.is_set(Permission::SelectBasicAds), - "Purpose 2 consent should set advertising_marketing.first_party.contextual" - ); - // Purpose 7 was refused, so the granted baseline is revoked. - assert!( - !state.is_set(Permission::MeasureAdPerformance), - "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" - ); - // The originally wired purposes still behave. - assert!( - state.is_set(Permission::StoreOnDevice) - && state.is_set(Permission::SelectPersonalisedAds), - "Purposes 1 and 4 remain resolved from the TCF record" - ); - } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 191bb7c84..f0625238b 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -476,9 +476,11 @@ mod tests { fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); - // A TCF record refusing storage is the withdrawal trigger. The test - // context resolves the storage baseline at the requires-signal floor, - // where refusing the signal storage depends on is destructive. + // A TCF record refusing storage is the withdrawal trigger, under a + // storage baseline at the requires-signal floor, where refusing the + // signal storage depends on is destructive. The TCF provider answers + // that at assembly, and core links no provider, so the answer is + // stated here and what finalization does with it is what is tested. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, tcf: Some(refusing_tcf()), @@ -486,7 +488,8 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -853,7 +856,10 @@ mod tests { source: ConsentSource::Cookie, ..Default::default() }; - let ec_context = canonicalizing_context(true, false, consent, false); + // The TCF provider answers the withdrawal at assembly, and core links + // no provider, so the answer is stated here. + let ec_context = canonicalizing_context(true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( @@ -1127,6 +1133,8 @@ mod tests { // created, but the deployment now runs a provider with a different // code, so read-back treats the cookie as absent and the active // identifier is empty. + // The TCF provider answers the withdrawal at assembly, and core links + // no provider, so the answer is stated here. let ec_context = make_context_with_consent( None, Some(CANONICAL_COOKIE_VALUE), @@ -1135,7 +1143,8 @@ mod tests { consent, false, ) - .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 91a5210a2..cb50db81a 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -76,7 +76,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; -use crate::permissions::{Acquisition, Permission, PermissionState}; +use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -149,11 +149,6 @@ pub struct EcContext { /// augmented by the session's signals. Assembled once at construction and /// read via [`permissions`](Self::permissions). permissions: PermissionState, - /// The jurisdiction's acquisition rule for Edge Cookie storage, resolved - /// once at construction and used by - /// [`storage_withdrawn`](Self::storage_withdrawn) to scope destructive - /// withdrawal. Defaults to the requires-signal floor. - storage_acquisition: Acquisition, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -347,16 +342,24 @@ impl EcContext { }); // Assemble the permission state once, here, through the permission - // model, building the country/region baseline augmented by the session's - // signals. Downstream consumers read the stored result via + // model, building the country/region baseline amended by what the + // signal providers the adapter selected say about the request. + // Downstream consumers read the stored result via // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than - // re-deriving it. - let permissions = consent::assemble_permissions_with( + // re-deriving it. The providers see the request as evidence, the same + // abstraction the Edge Cookie and device providers read, so a scheme + // core has never heard of can read its own signal from it. + let evidence = crate::evidence::BorrowedRequestInfo::new( + client_ip.as_deref().unwrap_or_default(), + Some(req.headers()), + ) + .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let permissions = consent::assemble_permissions( &consent, + &evidence, geo_status, - &consent::sources_for(settings.permission_signal.sources.as_deref()), + services.permission_signal_providers(), ); - let storage_acquisition = consent::storage_acquisition(geo_status); // With no provider selected nothing may create or use an identifier, so // the gate is closed rather than open by default. let ec_allowed = selected_provider @@ -379,7 +382,6 @@ impl EcContext { consent, ec_allowed, permissions, - storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -757,13 +759,15 @@ impl EcContext { /// Whether the request carries an explicit signal withdrawing Edge Cookie /// storage, scoped to the jurisdiction's storage baseline. /// - /// See [`consent::ec_storage_withdrawn`]: only a TCF record refusing - /// storage withdraws, and only where the storage baseline is not - /// `granted`. Suppression (the permission merely not set) is reported by + /// Answered by the signal providers at construction and recorded on the + /// permission state, see [`PermissionState::storage_withdrawn`]. Of the + /// schemes that ship, only a TCF record refusing storage withdraws, and + /// only where the storage baseline is not `granted`. Suppression (the + /// permission merely not set) is reported by /// [`ec_allowed`](Self::ec_allowed) being `false` instead. #[must_use] pub fn storage_withdrawn(&self) -> bool { - consent::ec_storage_withdrawn(&self.consent, self.storage_acquisition) + self.permissions.storage_withdrawn() } /// Whether the Edge Cookie identifier may be shared beyond the edge for @@ -864,7 +868,6 @@ impl EcContext { consent, ec_allowed, permissions, - storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -891,7 +894,6 @@ impl EcContext { ec_generated: false, consent, ec_allowed: true, - storage_acquisition: Acquisition::default(), permissions: PermissionState::default(), client_ip, geo_info: None, @@ -924,7 +926,6 @@ impl EcContext { consent, ec_allowed, permissions: PermissionState::default(), - storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -935,6 +936,21 @@ impl EcContext { response_headers: Vec::new(), } } + + /// The same context, recording that the request explicitly withdrew + /// storage, as assembly records it when a signal provider answers so. + /// + /// Core links no provider, so a core test cannot derive the withdrawal + /// from a consent record the way a deployment does through the TCF + /// provider. It states the answer instead and tests what finalization does + /// with it. That a TCF refusal produces this answer is proved where the + /// providers are linked, in the Axum adapter's `permission_signals` test. + #[cfg(test)] + #[must_use] + pub fn with_storage_withdrawn_for_test(mut self, withdrawn: bool) -> Self { + self.permissions = self.permissions.with_storage_withdrawn(withdrawn); + self + } } /// Returns the current Unix timestamp in seconds. diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index ed079e7d4..c1aa144a7 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -68,6 +68,23 @@ pub trait RequestInfo: Send + Sync + core::fmt::Debug { url::form_urlencoded::parse(self.query().as_bytes()) .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) } + + /// The value of request cookie `name`, or `None` when the request does not + /// carry it. + /// + /// Defaulted, parsing the `Cookie` header, so a provider that needs a + /// cookie does not write its own splitting and trimming and get it subtly + /// different from the next one. An implementation holding a parsed jar may + /// override it. Core keeps no list of which cookies belong to which + /// scheme, which is what lets a permission signal provider for a scheme + /// core has never heard of read its own signal. + fn cookie(&self, name: &str) -> Option<&str> { + let header = self.header("cookie")?; + header.split(';').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key.trim() == name).then(|| value.trim()) + }) + } } /// An owned [`RequestInfo`] built from a request snapshot. @@ -250,6 +267,65 @@ pub trait HostSignals: Send + Sync + core::fmt::Debug { fn h2(&self) -> Option<&str>; } +#[cfg(test)] +mod cookie_tests { + use http::HeaderMap; + + use super::*; + + fn with_cookie_header(value: &'static str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert("cookie", http::HeaderValue::from_static(value)); + OwnedRequestInfo::new(String::new(), headers) + } + + #[test] + fn reads_a_cookie_by_name_from_among_several() { + let info = with_cookie_header("a=1; euconsent-v2=CP-abc; b=2"); + assert_eq!( + info.cookie("euconsent-v2"), + Some("CP-abc"), + "the named cookie is found wherever it sits in the header" + ); + } + + #[test] + fn trims_the_spaces_browsers_put_around_pairs() { + let info = with_cookie_header(" a = 1 ; b=2 "); + assert_eq!( + info.cookie("a"), + Some("1"), + "spaces around the name and value are not part of either" + ); + assert_eq!(info.cookie("b"), Some("2")); + } + + #[test] + fn answers_nothing_for_a_cookie_that_is_absent() { + let info = with_cookie_header("a=1"); + assert_eq!( + info.cookie("b"), + None, + "an absent cookie is None, not an empty value" + ); + assert_eq!( + OwnedRequestInfo::default().cookie("a"), + None, + "and so is a request with no Cookie header at all" + ); + } + + #[test] + fn matches_the_whole_name_and_not_a_prefix() { + let info = with_cookie_header("session_id=abc; id=xyz"); + assert_eq!( + info.cookie("id"), + Some("xyz"), + "`id` must not match `session_id`" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 4dd0fdfdf..888385777 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -10,105 +10,178 @@ answer anyone gave to a question, and a jurisdiction rule is neither. Consent is one kind of signal, so the seam takes the wider name and the consent subsystem keeps the narrower one. +## Why the providers are not in core + +Privacy is a non-price factor of competition. Publishers, browsers and +standards bodies compete on it, and schemes come and go. Compiling a closed +list of schemes into core would settle that competition in code, because +the schemes built in would be the only ones a deployment could act on, and +the core maintainers would be deciding which privacy schemes exist. + +So core holds the trait, the ordering, the country baseline, and the policy +vocabulary for what a deployment decides about the shipped schemes, being +whether a TCF record answers, which signals count as a US-style opt-out and +what an opt-out takes away. It holds no scheme's wire format and no scheme's +meaning. Every scheme lives in its own crate outside core, including the four +that ship by default, so none of them is privileged by being the one that +happens to be built in. Core does not know what a TCF purpose is, because the +mapping from purpose to Data Use lives in the TCF crate, and a deployment +that runs no TCF carries no table of another scheme's numbers. The purposes +are the IAB TCF Europe purposes and what they grant are IAB Tech Lab Privacy +Taxonomy Data Uses, so a reader checks the mapping against the industry's own +documents rather than against us, and docs/guide/permission-model.md lists it +in full. Two purposes have no Data Use yet, so the crate carries a proposed +key for one and the TCF identifier for the other until the taxonomy adds +them. + +A scheme that is not one of the four is added the same way, as a crate that +reads its own signal from the request, without a change to core. The next one +is Model Terms for Marketing (MTM), where a publisher and the parties it passes +data to agree to be bound by a published set of terms, and what a provider +reads is whether that agreement covers this request. MTM arrives in a following +pull request, so the four here are a starting set and not the list. + ## The hierarchy Permissions are resolved in layers, each amending the one before. ```text - country / region rules the baseline: granted, requires-signal, denied + country / region rules the baseline: granted, requires-signal, denied | v - source 1 (configured order) may amend + provider 1 (configured order) may amend | v - source 2 may amend + provider 2 may amend | v - ... may amend + ... may amend | v the permission state for this request ``` The baseline comes from `permissions.yaml`, keyed by country and region, with -a default for a request whose place is unknown. A geo provider supplies the -place. No geo provider means no country, and the baseline falls to its floor. +the top node of the rules tree standing in for a request whose place is +unknown. A geo provider supplies the place. No geo provider means no country, +so every request resolves at that top node, and only a lookup that failed +resolves at the requires-signal floor, because a place that could not be +determined must not be treated as the declared default. -Sources are then asked in order. Each sees what the sources before it settled -on and may amend it. A source with no opinion returns `Neutral` and leaves the -prior value standing, which is different from refusing. +Providers are then asked in order. Each sees what the providers before it +settled on and may amend it. A provider with no opinion returns `Neutral` and +leaves the prior value standing, which is different from refusing. ## The order is the policy -The last source with an opinion decides, so the order is the policy. It is a -deployment's to set, not this code's to assume. +The last provider with an opinion decides, so the order is the policy. It is +a deployment's to set, not this code's to assume. ```toml [permission_signal] sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] ``` -A source not on the list does not run, and there is no separate switch. A +A provider not on the list does not run, and there is no separate switch. A publisher who does not want to act on Global Privacy Control removes `"gpc"` -from the list. Leaving the section out entirely runs every model the build -knows about, in the default order, so a signal is never quietly ignored -because someone forgot to list it. An unknown or repeated name is refused at -startup, so a typo cannot silently stop a model being honored. - -The default order asks the signals needing no interaction first and the ones -following a prompt after. Global Privacy Control withdraws personalisation on -arrival, and a visitor who then answers a prompt has their answer applied over -it. A deployment wanting the opposite puts the opt-out source last. - -Trusted Server takes no view on which model should win. That is a question +from the list, and the provider that reads the header then does not run. One +caveat: core's consent pipeline can also synthesize a US Privacy opt-out from +that header for a visitor in a US state, when the consent settings say to, +which they do by default, and the `us-privacy` provider then acts on the +record it produced. A publisher who wants the header to have no effect at all +turns that setting off as well. Leaving the section out entirely runs every +provider the adapter offers, in the order it offers them, so a signal is never +quietly ignored because someone forgot to list it. An unknown or repeated name +is refused at startup, so a typo cannot silently stop a scheme being honored. + +The default order asks the signal with no interface of its own first and the +ones carrying a choice made through an interface after. Global Privacy +Control is a browser setting, so it revokes personalization on arrival, +whereas a GPP sale opt-out, a US Privacy string and a TCF record each carry +an answer a person gave, so they are asked later and amend it. A deployment +wanting the browser setting to stand over a later answer puts `gpc` last. + +Trusted Server takes no view on which scheme should win. That is a question about a jurisdiction and a publisher. -## A source can see the others +## A provider can see the others -Amending well sometimes needs to know who set the prior value. A source is +Amending well sometimes needs to know who set the prior value. A provider is given the whole ordered list and its own position in it, so it can look up a peer by name, see whether a peer it cares about is configured at all, and ask a peer directly what that peer makes of a permission. -That is what makes a rule like "personalisation is off, but only because +That is what makes a rule like "personalization is off, but only because Global Privacy Control set it, so my answer supersedes it" expressible. The -rule itself belongs to whichever source wants it. This seam only makes the +rule itself belongs to whichever provider wants it. This seam only makes the information available. -Consulting a peer goes one level deep. A source answering a consultation -cannot consult in turn, so two sources asking each other cannot loop. - -## Writing a source - -Answer `Neutral` for a permission the source has no opinion on, including when -the signal it reads is absent from the request. Returning `Revoke` for an -absent signal turns silence into refusal and would revoke the permission on -every request not carrying that model, which is most of them. - -Read the policy for what a signal means rather than inventing a meaning. The -mapping from a permission to a TCF purpose, and which signals count as an -opt-out, are the policy's decisions so that a deployment can change them -without changing a source. - -## The models supplied - -Core supplies the models it already understood: Global Privacy Control, a GPP -sale opt-out, a US Privacy string, and TCF v2. A deployment configuring -nothing gets those, in that order. +Consulting a peer goes one level deep. A provider answering a consultation +cannot consult in turn, so two providers asking each other cannot loop. + +## Withdrawal is a separate question + +A provider may also say that the request explicitly *withdraws* a permission, +which is different from not granting it. A withdrawal of storage expires the +browser cookie and writes the authoritative tombstone against the identifier. +A permission that is merely not set strips the response headers and leaves an +already-issued identifier alone, so a returning visitor is not permanently +withdrawn before they get to answer. Most schemes have no such notion, a +browser setting and a sale opt-out included, and only TCF answers it. Core +then scopes the answer to the jurisdiction, so a refusal only withdraws where +the storage baseline did not grant storage outright, because where it did +the identifier never depended on the record. + +## Writing a provider + +Implement `PermissionSignalProvider` in a crate that depends on core. Answer +`Neutral` for a permission the provider has no opinion on, including when the +signal it reads is absent from the request. Returning `Revoke` for an absent +signal turns silence into refusal and would revoke the permission on every +request not carrying that scheme, which is most of them. + +Read the request through `SignalInput::evidence`, which offers headers, +cookies, the path and the query, so a scheme core has never heard of can read +its own signal. The decoded consent record is offered too, for the schemes +core's consent pipeline already decodes, caches against the identifier and +expires. Prefer the record where it exists, because a provider that +re-decodes the wire would skip the cached record on a returning visitor and +answer differently from every other reader of the same request. + +Read the policy for what is a deployment's decision rather than the scheme's, +such as which signals count as an opt-out and what an opt-out takes away, so +that a deployment can change those without changing a provider. + +Register the crate at the adapter's composition root, where every adapter +builds its `RuntimeServices`. The adapter lists the providers it links, in the +default order, and `build_permission_signal_providers` selects and orders them +from configuration. + +## The providers supplied + +Four crates ship, under `crates/permission-signal/`, and a deployment +configuring nothing gets all four in this order: + +| Identifier | Crate | Reads | +| ------------------ | ------------ | ------------------------------------------------- | +| `gpc` | `gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp-sale-opt-out` | `gpp` | The US sale opt-out in a GPP string | +| `us-privacy` | `us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `tcf` | A TCF v2 record, with its purpose mapping in code | The three opt-outs are separate rather than one so that a publisher who does not act on Global Privacy Control can remove it and keep the other two. -## What is not a source +## What is not a provider -A consent record that arrives and cannot be read revokes, ahead of every -configured model and whichever ones are configured. That is error handling, -not a signalling model, so it is not in the list and cannot be removed. A +A consent record that arrives and cannot be read revokes, ahead of the +providers and whichever of them are configured. That is error handling, not a +signaling scheme, so it is not in the list and cannot be removed. A publisher chooses which signals to act on; they do not choose what happens when one of those signals arrives unreadable. An unreadable record is a preference someone expressed that could not be read, which is not the same as no record at all, so it must not degrade to the no-signal baseline. It overrides rather than taking a place in the order, because the ordered rule -would otherwise let a readable record from one model overwrite the refusal +would otherwise let a readable record from one scheme overwrite the refusal caused by an unreadable one from another. diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index f8fac0af0..19686dfe6 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -2,160 +2,202 @@ use std::sync::Arc; +use error_stack::Report; + use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; +use crate::settings::Settings; -/// What a signal source may read about a request. +/// What a signal provider may read about a request. /// -/// A struct rather than a parameter list, so a source needing something new +/// A struct rather than a parameter list, so a provider needing something new /// does not change every implementation. pub struct SignalInput<'a> { /// The decoded consent record for this request. + /// + /// Offered because the schemes that ship by default already decode into + /// it, core keeps it against the Edge Cookie identifier between requests, + /// and the rest of the system reads it. A provider is not required to use + /// it, and a scheme core has never heard of will not appear in it. Such a + /// provider reads [`evidence`](Self::evidence) instead and decodes whatever + /// its scheme needs. pub consent: &'a ConsentContext, + /// The request itself, as evidence. + /// + /// This is the open half of the seam, and it is [`RequestInfo`], the same + /// abstraction the Edge Cookie and device providers already read. A + /// provider asks for the header, cookie, path or query parameter its own + /// scheme uses, so core holds no list of which evidence a scheme may read. + pub evidence: &'a dyn RequestInfo, /// The policy from `permissions.yaml`, which decides what a signal means - /// rather than leaving each source to invent its own meaning. + /// rather than leaving each provider to invent its own meaning. pub policy: &'a SignalPolicy, /// What the country and region rules say about this permission, before any - /// source is asked. A source amends this rather than deciding alone. + /// provider is asked. A provider amends this rather than deciding alone. pub baseline: Acquisition, - /// What the sources asked before this one settled on. + /// What the providers asked before this one settled on. /// /// [`ConsentSignal::Neutral`] means none of them had an opinion, so the /// baseline still stands. pub settled: ConsentSignal, - /// Every source in configured order, this one included. - sources: &'a [Arc], - /// Where in that order the source being asked sits. + /// Every provider in configured order, this one included. + providers: &'a [Arc], + /// Where in that order the provider being asked sits. position: usize, - /// Whether this source may consult a peer. + /// Whether this provider may consult a peer. /// - /// False while answering a consultation, which is what stops two sources + /// False while answering a consultation, which is what stops two providers /// that consult each other from looping. may_ask: bool, } impl<'a> SignalInput<'a> { - /// An input for a source asked on its own, outside an ordered run. + /// An input for a provider asked on its own, outside an ordered run. #[must_use] pub fn new( consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, policy: &'a SignalPolicy, baseline: Acquisition, ) -> Self { Self { consent, + evidence, policy, baseline, settled: ConsentSignal::Neutral, - sources: &[], + providers: &[], position: 0, may_ask: true, } } - /// Every source in configured order, this one included. + /// Every provider in configured order, this one included. /// - /// A source consults the list to decide whether a peer it cares about is + /// A provider consults the list to decide whether a peer it cares about is /// configured at all, and where it sits relative to this one. #[must_use] - pub fn sources(&self) -> &[Arc] { - self.sources + pub fn providers(&self) -> &[Arc] { + self.providers } - /// Where the source being asked sits in that order. + /// Where the provider being asked sits in that order. #[must_use] pub const fn position(&self) -> usize { self.position } - /// Whether a source with this identifier is configured. + /// Whether a provider with this identifier is configured. #[must_use] pub fn has(&self, id: &str) -> bool { - self.sources.iter().any(|source| source.id() == id) + self.providers.iter().any(|provider| provider.id() == id) } /// What a peer makes of `permission`, asked directly. /// /// The peer answers as if it were first, so the reply is that peer's own /// opinion rather than what the run has settled on so far. That is the - /// useful question: a source wanting to know whether the prior value came - /// from a particular peer asks that peer what it says. + /// useful question, because a provider wanting to know whether the prior + /// value came from a particular peer asks that peer what it says. /// - /// Returns `None` when no source carries the identifier, when the caller + /// Returns `None` when no provider carries the identifier, when the caller /// names itself, and when this input is itself answering a consultation. - /// A source must handle `None` rather than assume a peer is present. + /// A provider must handle `None` rather than assume a peer is present. #[must_use] pub fn ask(&self, id: &str, permission: Permission) -> Option { if !self.may_ask { return None; } - let (position, source) = self - .sources + let (position, provider) = self + .providers .iter() .enumerate() - .find(|(position, source)| source.id() == id && *position != self.position)?; + .find(|(position, provider)| provider.id() == id && *position != self.position)?; let input = Self { consent: self.consent, + evidence: self.evidence, policy: self.policy, baseline: self.baseline, settled: ConsentSignal::Neutral, - sources: self.sources, + providers: self.providers, position, may_ask: false, }; - Some(source.signal(permission, &input)) + Some(provider.signal(permission, &input)) } } -/// A source of permission signals. +/// A provider of permission signals. /// -/// An implementation answers for one signalling model. It reads the request, -/// applies whatever the policy says about its own model, and returns how it +/// An implementation answers for one signaling scheme. It reads the request, +/// applies whatever the policy says about its own scheme, and returns how it /// would amend one permission. /// /// # Contract /// -/// Answer [`ConsentSignal::Neutral`] for a permission this source has no +/// Answer [`ConsentSignal::Neutral`] for a permission this provider has no /// opinion on, including when the signal it reads is absent from the request. /// Returning [`ConsentSignal::Revoke`] for an absent signal would turn silence /// into refusal and revoke the permission on every request that did not carry -/// this model. -pub trait PermissionSignalSource: Send + Sync { +/// this scheme. +pub trait PermissionSignalProvider: Send + Sync { /// Stable identifier, used in configuration, in logs, and by a peer - /// looking this source up through [`SignalInput::ask`]. + /// looking this provider up through [`SignalInput::ask`]. fn id(&self) -> &'static str; - /// How this source would amend `permission` for this request. + /// How this provider would amend `permission` for this request. fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; + + /// Whether the request carries an explicit withdrawal of `permission` + /// under this scheme, as opposed to merely not granting it. + /// + /// The difference is destructive. A withdrawal of storage expires the + /// browser cookie and writes the authoritative tombstone against the + /// identifier, where a permission that is simply not set for this request + /// strips the response headers and leaves the identifier alone, so that a + /// returning visitor is not permanently withdrawn before they ever get to + /// answer. Most schemes have no such notion, a browser setting and a sale + /// opt-out included, and leave this at its default of `false`. Whether a + /// withdrawal is acted on at all is core's decision from the jurisdiction's + /// baseline, so a refusal only withdraws where the baseline did not grant + /// the permission outright, because where it did the permission never + /// depended on the record. + fn withdraws(&self, _permission: Permission, _input: &SignalInput<'_>) -> bool { + false + } } -/// Asks every source in order and returns what they settle on together. +/// Asks every provider in order and returns what they settle on together. /// /// See the module documentation for the layering and why the order is the /// configuration. #[must_use] -pub fn combine( - sources: &[Arc], +pub(crate) fn combine( + providers: &[Arc], permission: Permission, consent: &ConsentContext, + evidence: &dyn RequestInfo, policy: &SignalPolicy, baseline: Acquisition, ) -> ConsentSignal { let mut settled = ConsentSignal::Neutral; - for (position, source) in sources.iter().enumerate() { + for (position, provider) in providers.iter().enumerate() { let input = SignalInput { consent, + evidence, policy, baseline, settled, - sources, + providers, position, may_ask: true, }; - // Every source is asked, because a later one may amend what an earlier - // one settled. Stopping at the first answer would make the order mean - // the opposite of what it says. - match source.signal(permission, &input) { + // Every provider is asked, because a later one may amend what an + // earlier one settled. Stopping at the first answer would make the + // order mean the opposite of what it says. + match provider.signal(permission, &input) { ConsentSignal::Neutral => {} answer => settled = answer, } @@ -163,15 +205,172 @@ pub fn combine( settled } +/// Whether the request explicitly withdraws `permission`, scoped to the +/// jurisdiction's baseline for it. +/// +/// A withdrawal only counts where the baseline did not grant the permission +/// outright. Under a `requires_signal` baseline a refusal is the visitor +/// declining the very signal the permission depended on, so it is +/// destructive. Under a `granted` baseline the permission never depended on +/// the record, so the same refusal suppresses use for the request without +/// destroying anything. Any one provider answering [`withdraws`] is enough, +/// and no provider answering it, or none configured, is never a withdrawal. +/// +/// [`withdraws`]: PermissionSignalProvider::withdraws +#[must_use] +pub(crate) fn withdrawn( + providers: &[Arc], + permission: Permission, + consent: &ConsentContext, + evidence: &dyn RequestInfo, + policy: &SignalPolicy, + baseline: Acquisition, +) -> bool { + if matches!(baseline, Acquisition::Granted) { + return false; + } + providers.iter().enumerate().any(|(position, provider)| { + let input = SignalInput { + consent, + evidence, + policy, + baseline, + settled: ConsentSignal::Neutral, + providers, + position, + may_ask: true, + }; + provider.withdraws(permission, &input) + }) +} + +/// The providers a deployment named, in the order it named them, drawn from +/// the ones the build makes available. +/// +/// `None` means nothing was configured, which runs every available provider +/// in the order the adapter offered them. That is deliberate, so a publisher +/// gets every scheme the build knows about until they say otherwise and a +/// signal is never quietly ignored because someone forgot to list it. An +/// empty list is a publisher acting on no signal at all, and is accepted. +/// +/// # Errors +/// +/// A name matching no available provider, or a name given twice, is refused +/// rather than ignored, so a typo cannot silently stop a scheme being honored +/// and a scheme cannot run twice at two places in the order. +pub(crate) fn select( + available: &[Arc], + configured: Option<&[String]>, +) -> Result>, Report> { + let Some(names) = configured else { + return Ok(available.to_vec()); + }; + let mut selected = Vec::with_capacity(names.len()); + for (position, name) in names.iter().enumerate() { + if names[..position].contains(name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal provider `{name}` is named more than once in \ + [permission_signal] sources. Each provider runs once, at one place \ + in the order" + ), + })); + } + let Some(provider) = available.iter().find(|provider| provider.id() == name) else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal provider `{name}` is not available in this build. \ + Available providers are {}", + ids(available).join(", ") + ), + })); + }; + selected.push(Arc::clone(provider)); + } + Ok(selected) +} + +/// The available providers a configured list leaves out, for the startup log. +#[must_use] +pub(crate) fn omitted<'a>( + available: &'a [Arc], + configured: Option<&[String]>, +) -> Vec<&'a str> { + let Some(names) = configured else { + return Vec::new(); + }; + available + .iter() + .map(|provider| provider.id()) + .filter(|id| !names.iter().any(|name| name == id)) + .collect() +} + +/// The identifiers of `providers`, in order. +#[must_use] +pub(crate) fn ids(providers: &[Arc]) -> Vec<&str> { + providers.iter().map(|provider| provider.id()).collect() +} + +/// Selects the providers a deployment runs from the ones an adapter makes +/// available, and says so in the log once at startup. +/// +/// This is the adapter's composition point. Core supplies no provider of its +/// own, so an adapter hands in every scheme crate it links, in the order that +/// stands when configuration names none, and receives back the ordered list +/// the request path asks. The list is shared rather than owned, so handing it +/// to every request's services is one reference count and no copy. +/// +/// # Errors +/// +/// A configured name that matches nothing available, or a name given twice, +/// fails startup with a message naming what is available, so a typo cannot +/// silently stop a scheme being honored. +pub fn build_permission_signal_providers( + settings: &Settings, + available: &[Arc], +) -> Result]>, Report> { + let configured = settings.permission_signal.sources.as_deref(); + let selected = select(available, configured)?; + match configured { + None => log::info!( + "Permission signals: acting on every provider this build offers, [{}], no \ + [permission_signal] sources configured", + ids(&selected).join(", ") + ), + Some([]) => log::info!( + "Permission signals: acting on no provider, [permission_signal] sources is \ + empty, so every permission stays at its country and region baseline" + ), + Some(_) => log::info!( + "Permission signals: acting on [{}], asked in that order", + ids(&selected).join(", ") + ), + } + let left_out = omitted(available, configured); + if !left_out.is_empty() { + log::warn!( + "Permission signals: not acting on [{}], which are not in [permission_signal] \ + sources. A signal this deployment does not act on is read from the request \ + and then ignored", + left_out.join(", ") + ); + } + Ok(Arc::from(selected)) +} + #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; + use crate::evidence::OwnedRequestInfo; - /// A source that always answers the same thing, for testing the rule - /// rather than any particular model. + /// A provider that always answers the same thing, for testing the rule + /// rather than any particular scheme. struct Fixed(&'static str, ConsentSignal); - impl PermissionSignalSource for Fixed { + impl PermissionSignalProvider for Fixed { fn id(&self) -> &'static str { self.0 } @@ -181,56 +380,106 @@ mod tests { } } - /// A source that answers by consulting a peer, which is the behavior the + /// A provider that answers by consulting a peer, which is the behavior the /// peer visibility exists for. struct Consulting { id: &'static str, peer: &'static str, } - impl PermissionSignalSource for Consulting { + impl PermissionSignalProvider for Consulting { fn id(&self) -> &'static str { self.id } fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { match input.ask(self.peer, permission) { - // The peer refused, and this source takes the opposite view of - // the same request, which is the override the seam allows. + // The peer refused, and this provider takes the opposite view + // of the same request, which is the override the seam allows. Some(ConsentSignal::Revoke) => ConsentSignal::Grant, _ => ConsentSignal::Neutral, } } } - fn combined(sources: &[Arc]) -> ConsentSignal { + /// A provider that withdraws storage, for testing the scoping rule. + struct Withdrawing; + + impl PermissionSignalProvider for Withdrawing { + fn id(&self) -> &'static str { + "withdrawing" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Revoke + } + + fn withdraws(&self, permission: Permission, _input: &SignalInput<'_>) -> bool { + permission == Permission::StoreOnDevice + } + } + + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) + } + + fn fixed(id: &'static str, signal: ConsentSignal) -> Arc { + Arc::new(Fixed(id, signal)) + } + + fn combined(providers: &[Arc]) -> ConsentSignal { let consent = ConsentContext::default(); let policy = SignalPolicy::default(); combine( - sources, + providers, Permission::StoreOnDevice, &consent, + &no_evidence(), &policy, Acquisition::RequiresSignal, ) } - /// The same, for a run whose sources differ only in what they answer. + /// The same, for a run whose providers differ only in what they answer. fn combined_signals(signals: &[ConsentSignal]) -> ConsentSignal { - let sources: Vec> = signals + let providers: Vec> = signals .iter() - .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .map(|signal| fixed("test", *signal)) .collect(); - combined(&sources) + combined(&providers) + } + + fn withdrawn_under( + providers: &[Arc], + baseline: Acquisition, + ) -> bool { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + withdrawn( + providers, + Permission::StoreOnDevice, + &consent, + &no_evidence(), + &policy, + baseline, + ) + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() } + // ------------------------------------------------------------------ + // Combining answers in order. + // ------------------------------------------------------------------ + #[test] - fn no_sources_leaves_the_place_baseline_alone() { + fn no_providers_leaves_the_place_baseline_alone() { assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); } #[test] - fn the_last_source_with_an_opinion_decides() { + fn the_last_provider_with_an_opinion_decides() { assert_eq!( combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Grant]), ConsentSignal::Grant, @@ -246,8 +495,8 @@ mod tests { #[test] fn silence_leaves_an_earlier_answer_standing() { - // The failure this guards is a later source overwriting a settled - // answer with its own absence, which would let adding a source nobody + // The failure this guards is a later provider overwriting a settled + // answer with its own absence, which would let adding a provider nobody // uses undo the one that was working. assert_eq!( combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), @@ -260,10 +509,10 @@ mod tests { } #[test] - fn silence_from_every_source_is_not_a_refusal() { - // The failure this guards is a source that reads an absent signal as a - // refusal. It would revoke the permission on every request that did not - // carry that model, which is most of them. + fn silence_from_every_provider_is_not_a_refusal() { + // The failure this guards is a provider that reads an absent signal as + // a refusal. It would revoke the permission on every request that did + // not carry that scheme, which is most of them. assert_eq!( combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), ConsentSignal::Neutral @@ -271,10 +520,10 @@ mod tests { } #[test] - fn a_source_sees_what_the_earlier_ones_settled() { + fn a_provider_sees_what_the_earlier_ones_settled() { struct Recording; - impl PermissionSignalSource for Recording { + impl PermissionSignalProvider for Recording { fn id(&self) -> &'static str { "recording" } @@ -283,10 +532,10 @@ mod tests { assert_eq!( input.settled, ConsentSignal::Revoke, - "a source is asked with the value the sources before it settled on" + "a provider is asked with the value the providers before it settled on" ); assert_eq!(input.position(), 1, "and with its own place in the order"); - assert_eq!(input.sources().len(), 2, "and with the whole list"); + assert_eq!(input.providers().len(), 2, "and with the whole list"); assert_eq!( input.baseline, Acquisition::RequiresSignal, @@ -296,61 +545,59 @@ mod tests { } } - let sources: Vec> = vec![ - Arc::new(Fixed("opt-out", ConsentSignal::Revoke)), - Arc::new(Recording), - ]; - assert_eq!(combined(&sources), ConsentSignal::Revoke); + let providers: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke), Arc::new(Recording)]; + assert_eq!(combined(&providers), ConsentSignal::Revoke); } #[test] - fn a_source_can_override_a_peer_by_consulting_it() { - // The worked example from the README: a later source overrides a + fn a_provider_can_override_a_peer_by_consulting_it() { + // The worked example from the README: a later provider overrides a // refusal because of who made it, not merely that one was made. - let sources: Vec> = vec![ - Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + let providers: Vec> = vec![ + fixed("gpc", ConsentSignal::Revoke), Arc::new(Consulting { id: "prompt", peer: "gpc", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Grant); + assert_eq!(combined(&providers), ConsentSignal::Grant); - // The same source leaves the refusal alone when it came from a peer it - // was not told to override. - let sources: Vec> = vec![ - Arc::new(Fixed("other", ConsentSignal::Revoke)), + // The same provider leaves the refusal alone when it came from a peer + // it was not told to override. + let providers: Vec> = vec![ + fixed("other", ConsentSignal::Revoke), Arc::new(Consulting { id: "prompt", peer: "gpc", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Revoke); + assert_eq!(combined(&providers), ConsentSignal::Revoke); } #[test] fn asking_reaches_a_peer_wherever_it_sits_in_the_order() { - // A source may consult one configured after it, not only before, so a - // reordering does not silently change what a source can see. - let sources: Vec> = vec![ + // A provider may consult one configured after it, not only before, so + // a reordering does not silently change what a provider can see. + let providers: Vec> = vec![ Arc::new(Consulting { id: "prompt", peer: "gpc", }), - Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + fixed("gpc", ConsentSignal::Revoke), ]; assert_eq!( - combined(&sources), + combined(&providers), ConsentSignal::Revoke, "the consultation succeeded, and the later opt-out then settled it" ); } #[test] - fn asking_for_a_source_that_is_not_configured_answers_nothing() { + fn asking_for_a_provider_that_is_not_configured_answers_nothing() { struct Absent; - impl PermissionSignalSource for Absent { + impl PermissionSignalProvider for Absent { fn id(&self) -> &'static str { "absent" } @@ -358,7 +605,7 @@ mod tests { fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { assert!( input.ask("not-configured", permission).is_none(), - "a source must be able to tell a missing peer from a silent one" + "a provider must be able to tell a missing peer from a silent one" ); assert!(!input.has("not-configured")); assert!(input.has("absent"), "and can see itself in the list"); @@ -366,15 +613,15 @@ mod tests { } } - let sources: Vec> = vec![Arc::new(Absent)]; - assert_eq!(combined(&sources), ConsentSignal::Neutral); + let providers: Vec> = vec![Arc::new(Absent)]; + assert_eq!(combined(&providers), ConsentSignal::Neutral); } #[test] - fn a_source_cannot_consult_itself() { + fn a_provider_cannot_consult_itself() { struct SelfAsking; - impl PermissionSignalSource for SelfAsking { + impl PermissionSignalProvider for SelfAsking { fn id(&self) -> &'static str { "self-asking" } @@ -386,16 +633,16 @@ mod tests { } } - let sources: Vec> = vec![Arc::new(SelfAsking)]; - assert_eq!(combined(&sources), ConsentSignal::Grant); + let providers: Vec> = vec![Arc::new(SelfAsking)]; + assert_eq!(combined(&providers), ConsentSignal::Grant); } #[test] - fn two_sources_that_consult_each_other_do_not_loop() { + fn two_providers_that_consult_each_other_do_not_loop() { // Each consults the other, and the one answering a consultation is // refused a consultation of its own, so the pair settles instead of // recursing. - let sources: Vec> = vec![ + let providers: Vec> = vec![ Arc::new(Consulting { id: "first", peer: "second", @@ -405,6 +652,126 @@ mod tests { peer: "first", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Neutral); + assert_eq!(combined(&providers), ConsentSignal::Neutral); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping. + // ------------------------------------------------------------------ + + #[test] + fn a_withdrawal_counts_only_where_the_baseline_did_not_grant() { + let providers: Vec> = vec![Arc::new(Withdrawing)]; + assert!( + withdrawn_under(&providers, Acquisition::RequiresSignal), + "refusing the signal the permission depended on is destructive" + ); + assert!( + withdrawn_under(&providers, Acquisition::Denied), + "and so is refusing under a baseline that never allowed it" + ); + assert!( + !withdrawn_under(&providers, Acquisition::Granted), + "where the permission never depended on the record, the refusal suppresses \ + without destroying" + ); + } + + #[test] + fn a_provider_that_merely_revokes_does_not_withdraw() { + // Revoke and withdraw are different questions. A sale opt-out revokes + // and must never destroy an identifier. + let providers: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke)]; + assert!(!withdrawn_under(&providers, Acquisition::RequiresSignal)); + } + + #[test] + fn no_providers_never_withdraw() { + assert!(!withdrawn_under(&[], Acquisition::RequiresSignal)); + } + + // ------------------------------------------------------------------ + // Selecting which providers run. + // ------------------------------------------------------------------ + + fn four() -> Vec> { + vec![ + fixed("gpc", ConsentSignal::Neutral), + fixed("gpp-sale-opt-out", ConsentSignal::Neutral), + fixed("us-privacy", ConsentSignal::Neutral), + fixed("tcf", ConsentSignal::Neutral), + ] + } + + #[test] + fn naming_nothing_runs_every_available_provider_in_the_offered_order() { + let selected = select(&four(), None).expect("should accept no configuration"); + assert_eq!( + ids(&selected), + vec!["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"], + "a publisher who configures nothing acts on every scheme the build knows, so \ + one is never ignored because they forgot to list it" + ); + } + + #[test] + fn the_configured_order_is_the_order_they_run_in() { + let reversed = names(&["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"]); + let selected = select(&four(), Some(&reversed)).expect("should accept known names"); + assert_eq!( + ids(&selected), + vec!["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"], + "the list is the order, not merely the membership" + ); + } + + #[test] + fn an_empty_list_is_acting_on_no_signal_and_is_accepted() { + let selected = select(&four(), Some(&[])).expect("should accept an empty list"); + assert!(selected.is_empty()); + assert_eq!( + omitted(&four(), Some(&[])).len(), + 4, + "and every provider is reported left out" + ); + } + + #[test] + fn a_name_matching_no_available_provider_is_refused() { + // Matched rather than `expect_err`, because the success type holds + // trait objects that are deliberately not `Debug`. + let Err(error) = select(&four(), Some(&names(&["gpc", "not-a-provider"]))) else { + panic!("should refuse a name this build does not offer"); + }; + let message = format!("{error:?}"); + assert!( + message.contains("not-a-provider") && message.contains("gpc, gpp-sale-opt-out"), + "the refusal names the bad entry and what is available: {message}" + ); + } + + #[test] + fn naming_a_provider_twice_is_refused() { + let Err(error) = select(&four(), Some(&names(&["gpc", "tcf", "gpc"]))) else { + panic!("should refuse a provider named twice"); + }; + assert!( + format!("{error:?}").contains("more than once"), + "a provider runs once, at one place in the order" + ); + } + + #[test] + fn a_left_out_provider_is_reported_as_omitted() { + let configured = names(&["gpc", "tcf"]); + assert_eq!( + omitted(&four(), Some(&configured)), + vec!["gpp-sale-opt-out", "us-privacy"] + ); + assert!( + omitted(&four(), None).is_empty(), + "configuring nothing omits nothing" + ); } } diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1907302ce..ea6445152 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -376,21 +376,23 @@ enum RevokeSet { Set(PermissionSet), } -/// How each session signal maps onto permissions, parsed from the `signals` -/// section of `permissions.yaml`. +/// What a deployment decides about the shipped signal schemes, parsed from the +/// `signals` section of `permissions.yaml`. /// -/// The permission model holds this as data so the consent mapping applies it -/// rather than encoding any signal policy in the code. It is jurisdiction-free: -/// it says only how a decoded signal grants or revokes each Data Use, and the +/// The permission model holds this as data so a deployment changes it without +/// changing a provider. It is jurisdiction-free, and it carries only the +/// decisions that are a deployment's to make: whether a TCF record answers at +/// all, which signals count as a US-style opt-out, and what an opt-out takes +/// away. What each scheme's own signal means, such as which TCF purpose grants +/// which Data Use, is that scheme's provider crate's, not this policy's. The /// country/region baseline decides the rest. #[derive(Debug, Clone, Default)] pub struct SignalPolicy { - /// Whether a present TCF record's grants and revokes apply. This never - /// lets a TCF record override an opt-out signal: an opt-out always - /// suppresses the Data Uses it revokes. + /// Whether a present TCF record's grants and revokes apply. Whether a + /// consenting record then stands over an opt-out, or the opt-out over it, + /// is decided by the order the providers are asked in, which is + /// `[permission_signal] sources`, not by this flag. tcf_authoritative: bool, - /// Permission bit index to the TCF purpose number that grants it. - tcf_purpose: BTreeMap, /// The signals that constitute a US-style opt-out. opt_out_sources: Vec, /// Which Data Uses a US-style opt-out revokes. @@ -404,13 +406,6 @@ impl SignalPolicy { self.tcf_authoritative } - /// The TCF purpose number that grants `permission`, or `None` when no purpose - /// maps to it. - #[must_use] - pub fn tcf_purpose(&self, permission: Permission) -> Option { - self.tcf_purpose.get(&permission.index()).copied() - } - /// The signals that constitute a US-style opt-out. #[must_use] pub fn opt_out_sources(&self) -> &[OptOutSource] { @@ -428,37 +423,12 @@ impl SignalPolicy { } } -/// Errors when a Data Use is granted by more than one TCF purpose, because the -/// grant-and-revoke rule needs a single purpose to answer for each Data Use. -fn ensure_none_signal_duplicate( - previous: Option, - data_use: &str, -) -> Result<(), PermissionsError> { - match previous { - None => Ok(()), - Some(_) => Err(PermissionsError::DuplicateTcfDataUse { - name: data_use.to_owned(), - }), - } -} - /// Builds a validated [`SignalPolicy`] from the parsed `signals` section, /// erroring when it names an unknown Data Use or revoke rule. fn build_signal_policy(spec: &SignalsSpec) -> Result { let mut policy = SignalPolicy::default(); if let Some(tcf) = &spec.tcf { policy.tcf_authoritative = tcf.authoritative; - for (purpose, data_uses) in &tcf.purposes { - for data_use in data_uses.identifiers() { - let permission = Permission::from_identifier(data_use).ok_or_else(|| { - PermissionsError::UnknownPermission { - name: data_use.clone(), - } - })?; - let previous = policy.tcf_purpose.insert(permission.index(), *purpose); - ensure_none_signal_duplicate(previous, data_use)?; - } - } } if let Some(opt_out) = &spec.us_opt_out { policy.opt_out_sources = opt_out.sources.clone(); @@ -528,7 +498,10 @@ impl PermissionMaps { /// `permissions.yaml`. The consent mapping reads this rather than encoding /// any signal policy in the code. #[must_use] - pub(crate) fn signals(&self) -> &SignalPolicy { + // Public because the permission signal provider crates live outside core + // and read the deployment's policy, in their tests and where a provider + // needs the shipped decisions rather than a policy built by hand. + pub fn signals(&self) -> &SignalPolicy { &self.signals } @@ -752,7 +725,7 @@ impl PermissionMaps { } }) .collect(); - PermissionState { set } + PermissionState::new(set) } /// The baseline permission state for a country and region with no session @@ -792,14 +765,48 @@ impl PermissionMaps { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PermissionState { set: PermissionSet, + /// Whether the request explicitly withdrew device storage, as opposed to + /// storage merely not being set. See + /// [`storage_withdrawn`](Self::storage_withdrawn). + storage_withdrawn: bool, } impl PermissionState { - /// Builds a state in which exactly the permissions in `set` are set, for - /// tests and callers that compute the set directly. + /// Builds a state in which exactly the permissions in `set` are set, and + /// nothing is withdrawn, for tests and callers that compute the set + /// directly. #[must_use] pub const fn new(set: PermissionSet) -> Self { - Self { set } + Self { + set, + storage_withdrawn: false, + } + } + + /// The same state, recording whether device storage was explicitly + /// withdrawn. Set by assembly from what the signal providers answered, + /// scoped to the jurisdiction's storage baseline. + #[must_use] + pub const fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { + Self { + storage_withdrawn, + ..self + } + } + + /// Whether the request carries an explicit signal withdrawing device + /// storage, rather than merely lacking the permission. + /// + /// The difference is destructive. A withdrawal expires the browser cookie + /// and writes the authoritative identity-graph tombstone, where a + /// permission that is simply not set strips the Edge Cookie response + /// headers and leaves an already-issued identifier alone, so a returning + /// visitor is not permanently withdrawn before they ever get to answer. + /// Which scheme can withdraw is each provider's to say, and only where the + /// jurisdiction's storage baseline did not grant storage outright. + #[must_use] + pub const fn storage_withdrawn(&self) -> bool { + self.storage_withdrawn } /// Whether a single permission is set. @@ -894,35 +901,16 @@ struct SignalsSpec { } /// The `signals.tcf` block. +/// +/// Only whether a TCF record answers for this deployment. Which purpose grants +/// which Data Use is the TCF scheme's own knowledge and lives in the TCF +/// permission signal provider crate, so this file carries no table of another +/// scheme's numbers and a deployment running no TCF configures none. #[derive(Debug, Deserialize)] struct TcfSignalSpec { /// Whether a present TCF record's grants and revokes apply. #[serde(default = "default_true")] authoritative: bool, - /// TCF purpose number to the Data Use, or list of Data Uses, it grants - /// (and revokes when the record does not consent to that purpose). - #[serde(default)] - purposes: BTreeMap, -} - -/// One Data Use, or a list of Data Uses, granted by a single TCF purpose. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum DataUseList { - /// A single Data Use identifier. - One(String), - /// A list of Data Use identifiers. - Many(Vec), -} - -impl DataUseList { - /// The Data Use identifiers this value names, in written order. - fn identifiers(&self) -> &[String] { - match self { - DataUseList::One(one) => core::slice::from_ref(one), - DataUseList::Many(many) => many, - } - } } /// The `signals.us_opt_out` block. @@ -1304,11 +1292,6 @@ pub enum PermissionsError { /// A permission flag or modification named an unknown permission. #[display("unknown permission `{name}`")] UnknownPermission { name: String }, - /// A Data Use appeared under more than one TCF purpose in `signals.tcf`. - #[display( - "Data Use `{name}` is granted by more than one TCF purpose; map each Data Use to a single purpose" - )] - DuplicateTcfDataUse { name: String }, /// An acquisition rule was not `granted`, `requires_signal`, or `denied`. #[display("unknown acquisition rule `{value}` (expected granted, requires_signal, or denied)")] UnknownAcquisition { value: String }, @@ -2238,9 +2221,6 @@ rules: signals: tcf: authoritative: true - purposes: - 1: necessary.operations.storage - 4: advertising_marketing.first_party.targeted us_opt_out: sources: [gpc] revokes: [advertising_marketing.first_party.targeted] @@ -2248,21 +2228,6 @@ signals: let maps = PermissionMaps::from_yaml(yaml).expect("should parse the signals section"); let signals = maps.signals(); assert!(signals.tcf_authoritative(), "tcf should be authoritative"); - assert_eq!( - signals.tcf_purpose(Permission::StoreOnDevice), - Some(1), - "Purpose 1 should map to device storage" - ); - assert_eq!( - signals.tcf_purpose(Permission::SelectPersonalisedAds), - Some(4), - "Purpose 4 should map to targeted advertising" - ); - assert_eq!( - signals.tcf_purpose(Permission::CreateAdsProfile), - None, - "an unmapped Data Use has no purpose" - ); assert!( signals.opt_out_revokes(Permission::SelectPersonalisedAds), "a listed Data Use is revoked by the opt-out" diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 9f5320805..9686f80e6 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -11,6 +11,7 @@ use super::{ }; use crate::ec::provider::EdgeCookieProvider; use crate::evidence::HostSignals; +use crate::permission_signal::PermissionSignalProvider; /// Geographic information extracted from a request. /// @@ -202,6 +203,13 @@ pub struct RuntimeServices { /// path resolves the selection itself, which is what a deployment that /// selects no provider, the Axum adapter, and the core tests all do. pub(crate) resolved_ec_provider: Option>, + /// The permission signal providers this deployment runs, in the order + /// they are asked, selected at the composition root from the scheme + /// crates the adapter links. Empty when the adapter offers none, in which + /// case every permission stays at its country and region baseline. Shared, + /// so building the services for a request bumps a reference count rather + /// than copying the list, and cloning the services does the same. + pub(crate) permission_signal_providers: Arc<[Arc]>, } impl RuntimeServices { @@ -316,6 +324,12 @@ impl RuntimeServices { self.resolved_ec_provider.clone() } + /// The permission signal providers this deployment runs, in order. + #[must_use] + pub fn permission_signal_providers(&self) -> &[Arc] { + &self.permission_signal_providers + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -404,6 +418,7 @@ pub struct RuntimeServicesBuilder { client_info: Option, host_signals: Option>, resolved_ec_provider: Option>, + permission_signal_providers: Arc<[Arc]>, } impl RuntimeServicesBuilder { @@ -421,6 +436,7 @@ impl RuntimeServicesBuilder { client_info: None, host_signals: None, resolved_ec_provider: None, + permission_signal_providers: Arc::default(), } } @@ -523,6 +539,25 @@ impl RuntimeServicesBuilder { self } + /// Set the permission signal providers this deployment runs, in the order + /// they are asked. + /// + /// Optional, and empty when unset. An adapter hands in the shared list + /// [`build_permission_signal_providers`] selected from the scheme crates it + /// links, so the request path asks exactly the providers configuration + /// named, in that order, and core supplies none of its own. + /// + /// [`build_permission_signal_providers`]: + /// crate::permission_signal::build_permission_signal_providers + #[must_use] + pub fn permission_signal_providers( + mut self, + providers: Arc<[Arc]>, + ) -> Self { + self.permission_signal_providers = providers; + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -565,6 +600,7 @@ impl RuntimeServicesBuilder { .expect("should set client_info before building RuntimeServices"), host_signals: self.host_signals, resolved_ec_provider: self.resolved_ec_provider, + permission_signal_providers: self.permission_signal_providers, } } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 520225b3b..c167fe0aa 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -891,120 +891,38 @@ impl DeviceConfig { } } -/// Which permission signal models run, and in what order. +/// Which permission signal providers run, and in what order. /// /// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, /// `[geo]` and `[device]` selectors, which each name one provider, signals /// compose: a request can carry a TCF string and a Global Privacy Control /// header at once and both have something to say. So this names a list, and -/// the order is the policy, because the last source with an opinion decides. +/// the order is the policy, because the last provider with an opinion decides. /// /// See `crates/trusted-server-core/src/permission_signal/README.md`. #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct PermissionSignalConfig { - /// The models to run, in order, named by the identifiers in - /// [`SOURCE_IDS`](crate::ec::consent::SOURCE_IDS). + /// The providers to run, in order, named by the identifier each provider + /// crate declares, for example `gpc`, `gpp-sale-opt-out`, `us-privacy` and + /// `tcf` for the four that ship. /// - /// Absent means every model the build knows about, in the default order. - /// A publisher who does not want to act on one removes it from the list; - /// there is no separate switch, because a model that is not listed does not - /// run. An empty list runs none of them, leaving every permission at its - /// country and region baseline. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sources: Option>, -} - -impl PermissionSignalConfig { - /// The models this configuration leaves out, in the declared order. - /// - /// Empty when nothing is configured, since that runs every model. - #[must_use] - pub fn omitted_sources(&self) -> Vec<&'static str> { - let Some(names) = self.sources.as_deref() else { - return Vec::new(); - }; - crate::ec::consent::SOURCE_IDS - .iter() - .copied() - .filter(|id| !names.iter().any(|name| name.as_str() == *id)) - .collect() - } - - /// Records the selection at startup, so which signals a deployment acts on - /// can be read from its log rather than inferred from its behavior. + /// Absent means every provider the adapter offers, in the order it offers + /// them. A publisher who does not want to act on one removes it from the + /// list, and there is no separate switch, because a provider that is not + /// listed does not run. An empty list runs none of them, leaving every + /// permission at its country and region baseline. /// - /// Any model left out is warned about, not merely noted. Removing one is a - /// deliberate choice a publisher is entitled to make, so it is not a - /// refusal, but a signal arriving on a request and then being ignored is - /// worth seeing in a log when someone asks why it had no effect. - pub fn log_selection(&self) { - let Some(names) = self.sources.as_deref() else { - log::info!( - "Permission signals: acting on every model, no [permission_signal] sources \ - configured" - ); - return; - }; - - if names.is_empty() { - log::info!( - "Permission signals: acting on no model, [permission_signal] sources is \ - empty, so every permission stays at its country and region baseline" - ); - } else { - log::info!( - "Permission signals: acting on {}, asked in that order", - names.join(", ") - ); - } - - let omitted = self.omitted_sources(); - if !omitted.is_empty() { - log::warn!( - "Permission signals: not acting on {}, which are not in [permission_signal] \ - sources. A signal this deployment does not act on is read from the request \ - and then ignored", - omitted.join(", ") - ); - } - } - - /// Checks that every named model exists in this build and none is named - /// twice. + /// Which names are valid is only known where the provider crates are + /// linked, so the check that each name matches an available provider and + /// none is repeated happens at the adapter's composition root, through + /// [`build_permission_signal_providers`], and refuses startup rather than + /// silently ignoring a typo. /// - /// Run at startup, so a typo is a refusal to boot rather than a signal that - /// silently stops being honored. - /// - /// # Errors - /// - /// - [`TrustedServerError::Configuration`] if a name is unknown or repeated. - pub fn validate_selection(&self) -> Result<(), Report> { - let Some(names) = self.sources.as_deref() else { - return Ok(()); - }; - for (position, name) in names.iter().enumerate() { - if !crate::ec::consent::SOURCE_IDS.contains(&name.as_str()) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Permission signal source `{name}` is not available in this build. \ - Available sources are {}", - crate::ec::consent::SOURCE_IDS.join(", ") - ), - })); - } - if names[..position].contains(name) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Permission signal source `{name}` is named more than once in \ - [permission_signal] sources. Each source runs once, at one place \ - in the order" - ), - })); - } - } - Ok(()) - } + /// [`build_permission_signal_providers`]: + /// crate::permission_signal::build_permission_signal_providers + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, } /// Geo / IP intelligence configuration. @@ -3503,8 +3421,6 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; - settings.permission_signal.validate_selection()?; - settings.permission_signal.log_selection(); GeoConfig::validate_permission_policy()?; settings .geo @@ -8759,18 +8675,14 @@ formats = [{{ width = 300, height = 250 }}] mod permission_signal_config_tests { use super::*; - fn config(sources: Option<&[&str]>) -> PermissionSignalConfig { - PermissionSignalConfig { - sources: sources.map(|names| names.iter().map(|name| (*name).to_owned()).collect()), - } - } + // Which names are valid is only known where the scheme crates are linked, + // so the checks that a name matches an available provider, and that none + // is repeated, live with the seam in `permission_signal::select`. What is + // tested here is the shape of the section itself. #[test] - fn no_section_is_allowed_and_means_every_model() { + fn no_section_is_allowed_and_means_every_provider() { let config = PermissionSignalConfig::default(); - config - .validate_selection() - .expect("should accept a deployment that configures nothing"); assert!( config.sources.is_none(), "absent rather than empty, because the two mean opposite things" @@ -8778,94 +8690,31 @@ mod permission_signal_config_tests { } #[test] - fn every_declared_source_is_accepted() { - config(Some(crate::ec::consent::SOURCE_IDS)) - .validate_selection() - .expect("should accept the full list the example configuration ships"); - } - - #[test] - fn an_empty_list_is_accepted_as_acting_on_no_signal() { - config(Some(&[])) - .validate_selection() - .expect("should accept a publisher who acts on no signal at all"); - } - - #[test] - fn an_unknown_source_is_refused_at_startup() { - let error = config(Some(&["gpc", "gpq"])) - .validate_selection() - .expect_err("should refuse a name no model answers to"); - let message = format!("{error:?}"); - assert!( - message.contains("gpq"), - "the message should name the typo, so it can be found: {message}" - ); - assert!( - message.contains("gpc"), - "and list what was available: {message}" - ); - } - - #[test] - fn naming_a_source_twice_is_refused() { - let error = config(Some(&["gpc", "tcf", "gpc"])) - .validate_selection() - .expect_err("should refuse a repeat, which has no meaning in an ordered list"); - assert!(format!("{error:?}").contains("gpc")); - } - - #[test] - fn only_models_a_publisher_chooses_to_act_on_are_listed() { - // A malformed consent record fails closed whatever is configured, so it - // is deliberately not among the names. It is what happens when a signal - // arrives unreadable, not a signal anyone elects to honor. - assert!( - !crate::ec::consent::SOURCE_IDS.contains(&"malformed-record"), - "error handling must not be listed as though it were a signalling model" - ); - } - - #[test] - fn configuring_nothing_omits_nothing() { - assert!( - PermissionSignalConfig::default() - .omitted_sources() - .is_empty(), - "no section runs every model, so nothing is left out to report" - ); - } - - #[test] - fn a_dropped_model_is_reported_as_omitted() { - let omitted = config(Some(&["gpc", "tcf"])).omitted_sources(); + fn the_section_round_trips_through_toml() { + let parsed: PermissionSignalConfig = + toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); assert_eq!( - omitted, - vec!["gpp-sale-opt-out", "us-privacy"], - "what the log names has to be what was actually left out" + parsed.sources.as_deref(), + Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), + "the order written is the order read, because the order is the policy" ); } #[test] - fn an_empty_list_omits_every_model() { + fn an_empty_list_is_kept_apart_from_no_list() { + let parsed: PermissionSignalConfig = + toml::from_str("sources = []").expect("should parse an empty list"); assert_eq!( - config(Some(&[])).omitted_sources().len(), - crate::ec::consent::SOURCE_IDS.len(), - "acting on no signal leaves every model out, and the log says so" + parsed.sources.as_deref(), + Some(&[][..]), + "a publisher acting on no signal at all writes an empty list, and it must \ + not read back as having written nothing" ); } #[test] - fn the_section_round_trips_through_toml() { - let parsed: PermissionSignalConfig = - toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); - assert_eq!( - parsed.sources.as_deref(), - Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), - "the order written is the order read, because the order is the policy" - ); - parsed - .validate_selection() - .expect("should accept two known sources"); + fn an_unknown_key_is_refused() { + toml::from_str::(r#"source = ["gpc"]"#) + .expect_err("should refuse a misspelled key rather than silently ignore it"); } } From cce1f45933fde04074a80cb24c16a8a4f6e74cf4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:15 +0100 Subject: [PATCH 084/133] Ship the four IAB signal schemes as crates the adapters link Global Privacy Control, the GPP sale opt-out, the US Privacy string and TCF v2 each become a crate under crates/permission-signal, implementing PermissionSignalProvider against the record core's consent pipeline already decoded, so a returning visitor's cached record and the expiry rule are honored the same way by every reader of the request. The TCF crate owns the purpose mapping in code, with a test that every identifier in the table is a real Data Use and none is granted by two purposes, and answers the storage withdrawal question. The three opt-outs stay separate so a publisher who does not act on Global Privacy Control can remove it and keep the other two. Each crate carries the maintainers declaration the vendor crates carry. Every adapter links the four, in one order, selects them once at startup so a name no crate answers to fails there rather than on the first request, and hands the shared list to every request's services. The cross-scheme behavior that only shows when multiple providers run together, being a prompt's answer applying over an opt-out, one opt-out standing when another is removed, a scheme left off the list not running, and withdrawal being TCF's alone and scoped to the place, is tested in the Axum adapter, which links all four and runs natively. Nothing in these crates is vendor-specific, and core links none of them. --- CLAUDE.md | 4 +- Cargo.lock | 44 ++ Cargo.toml | 8 + crates/permission-signal/gpc/Cargo.toml | 24 + crates/permission-signal/gpc/src/lib.rs | 154 ++++++ crates/permission-signal/gpp/Cargo.toml | 24 + crates/permission-signal/gpp/src/lib.rs | 191 +++++++ crates/permission-signal/tcf/Cargo.toml | 24 + crates/permission-signal/tcf/src/lib.rs | 302 +++++++++++ crates/permission-signal/tcf/src/mapping.rs | 172 +++++++ .../permission-signal/us-privacy/Cargo.toml | 24 + .../permission-signal/us-privacy/src/lib.rs | 193 +++++++ crates/trusted-server-adapter-axum/Cargo.toml | 4 + crates/trusted-server-adapter-axum/src/app.rs | 40 +- .../src/platform.rs | 7 + .../tests/permission_signals.rs | 476 ++++++++++++++++++ .../Cargo.toml | 4 + .../src/app.rs | 35 +- .../src/platform.rs | 7 + .../trusted-server-adapter-fastly/Cargo.toml | 4 + .../trusted-server-adapter-fastly/src/app.rs | 38 ++ crates/trusted-server-adapter-spin/Cargo.toml | 4 + crates/trusted-server-adapter-spin/src/app.rs | 35 +- .../src/platform.rs | 14 +- 24 files changed, 1823 insertions(+), 9 deletions(-) create mode 100644 crates/permission-signal/gpc/Cargo.toml create mode 100644 crates/permission-signal/gpc/src/lib.rs create mode 100644 crates/permission-signal/gpp/Cargo.toml create mode 100644 crates/permission-signal/gpp/src/lib.rs create mode 100644 crates/permission-signal/tcf/Cargo.toml create mode 100644 crates/permission-signal/tcf/src/lib.rs create mode 100644 crates/permission-signal/tcf/src/mapping.rs create mode 100644 crates/permission-signal/us-privacy/Cargo.toml create mode 100644 crates/permission-signal/us-privacy/src/lib.rs create mode 100644 crates/trusted-server-adapter-axum/tests/permission_signals.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3aa298744..cdc6a596e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ crates/ fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) geo/ # vendor geo provider crates (host geo is injected by the adapter) + permission-signal/ # permission signal provider crates, one per scheme (gpc, gpp, tcf, us-privacy); core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -357,6 +358,7 @@ deployment selects an implementation and the core stays neutral: | Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | | Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | | Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] sources` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | Principles for adding or changing a provider: @@ -415,7 +417,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors) | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] sources` list) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/Cargo.lock b/Cargo.lock index 58f0c055f..3039eb0dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5369,6 +5369,10 @@ dependencies = [ "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5388,6 +5392,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "worker", ] @@ -5415,6 +5423,10 @@ dependencies = [ "trusted-server-core", "trusted-server-device-fastly", "trusted-server-geo-fastly", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "urlencoding", ] @@ -5439,6 +5451,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5601,6 +5617,34 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 3301e7777..3046f5bba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ resolver = "2" members = [ "crates/device/fastly", "crates/geo/fastly", + "crates/permission-signal/gpc", + "crates/permission-signal/gpp", + "crates/permission-signal/tcf", + "crates/permission-signal/us-privacy", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -114,6 +118,10 @@ tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } trusted-server-device-fastly = { path = "crates/device/fastly" } trusted-server-geo-fastly = { path = "crates/geo/fastly" } +trusted-server-permission-signal-gpc = { path = "crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "crates/permission-signal/us-privacy" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/permission-signal/gpc/Cargo.toml b/crates/permission-signal/gpc/Cargo.toml new file mode 100644 index 000000000..1e64caf2d --- /dev/null +++ b/crates/permission-signal/gpc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpc" +description = "Global Privacy Control as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs new file mode 100644 index 000000000..f744dba5c --- /dev/null +++ b/crates/permission-signal/gpc/src/lib.rs @@ -0,0 +1,154 @@ +//! Global Privacy Control as a permission signal provider. +//! +//! Answers from the `Sec-GPC` request header, which core reads into the +//! consent record's `gpc` flag. Separate from the GPP and US Privacy providers +//! so that a publisher who does not act on Global Privacy Control can leave +//! this one out of the configured list without also losing the other two +//! opt-outs. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "gpc"; + +/// The `Sec-GPC` request header, Global Privacy Control. +#[derive(Debug, Default, Clone, Copy)] +pub struct GpcProvider; + +impl GpcProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GpcProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input.policy.opt_out_sources().contains(&OptOutSource::Gpc) { + return ConsentSignal::Neutral; + } + if input.consent.gpc && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy lists this scheme as an opt-out and revokes device + /// storage on it, and leaves ad measurement alone. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_header(set: bool) -> ConsentContext { + ConsentContext { + gpc: set, + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GpcProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(GpcProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_when_the_header_is_set() { + assert_eq!( + answer( + &with_header(true), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_header(true), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "what an opt-out takes away is the policy's decision, and measurement is not listed" + ); + } + + #[test] + fn is_silent_when_the_header_is_absent() { + assert_eq!( + answer( + &with_header(false), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + // A policy declaring no opt-out sources at all. + let unlisted = SignalPolicy::default(); + assert_eq!( + answer(&with_header(true), &unlisted, Permission::StoreOnDevice), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even when the header is set" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_header(true); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GpcProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a browser setting suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml new file mode 100644 index 000000000..ecbcc7194 --- /dev/null +++ b/crates/permission-signal/gpp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpp" +description = "The GPP US sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs new file mode 100644 index 000000000..af0b504d8 --- /dev/null +++ b/crates/permission-signal/gpp/src/lib.rs @@ -0,0 +1,191 @@ +//! The GPP US sale opt-out as a permission signal provider. +//! +//! Answers from the US sale opt-out carried in the `__gpp` string, which core +//! decodes into the consent record. It says nothing about the EU TCF section a +//! GPP string may also carry, because that is the TCF provider's scheme. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "gpp-sale-opt-out"; + +/// A GPP US sale opt-out, read from the `__gpp` string. +#[derive(Debug, Default, Clone, Copy)] +pub struct GppSaleOptOutProvider; + +impl GppSaleOptOutProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GppSaleOptOutProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::GppSaleOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out) + == Some(true); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_opt_out(value: Option) -> ConsentContext { + ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: value, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GppSaleOptOutProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(GppSaleOptOutProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(false)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a section present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_opt_out(None), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a section carrying no sale flag is silence" + ); + } + + #[test] + fn is_silent_when_no_gpp_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_opt_out(Some(true)); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GppSaleOptOutProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml new file mode 100644 index 000000000..f57ca510c --- /dev/null +++ b/crates/permission-signal/tcf/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-tcf" +description = "IAB TCF v2 as a permission signal provider, with the purpose mapping." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs new file mode 100644 index 000000000..21e362942 --- /dev/null +++ b/crates/permission-signal/tcf/src/lib.rs @@ -0,0 +1,302 @@ +//! IAB TCF v2 as a permission signal provider. +//! +//! Answers from the decoded TCF record for the purposes this crate maps to +//! each permission, and is the one place that knows what a TCF purpose is. +//! Core decodes the TC string, keeps the record against the Edge Cookie +//! identifier, expires it by age and resolves it against a GPP EU section, and +//! this provider reads what that pipeline produced rather than decoding the +//! cookie a second time. Reading the wire directly would silently skip the +//! cached record on a returning visitor and the expiry rule, and answer +//! differently from every other reader of the same request. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +mod mapping; + +pub use mapping::purpose_for; + +use trusted_server_core::consent::effective_tcf; +#[cfg(test)] +use trusted_server_core::consent::types::TcfConsent; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "tcf"; + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is this crate's, in +/// [`purpose_for`], so core carries no table of another scheme's numbers. A +/// permission no purpose maps to gets silence, not a refusal. +#[derive(Debug, Default, Clone, Copy)] +pub struct TcfProvider; + +impl TcfProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for TcfProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy still says whether a TCF record answers for this + // deployment at all. What it no longer says is which purpose grants + // which permission, because that is this scheme's own knowledge. + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(purpose) = mapping::purpose_for(permission) else { + // TCF has nothing to say about this Data Use, so it says nothing. + return ConsentSignal::Neutral; + }; + let Some(record) = effective_tcf(input.consent) else { + // No TCF record on the request. Silence, not refusal, because + // reading an absent scheme as a refusal would revoke on every + // request that did not carry it. + return ConsentSignal::Neutral; + }; + if record.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal. Reading it + // as silence would leave the country baseline standing and grant + // what they declined. + ConsentSignal::Revoke + } + } + + /// Only a TCF record refusing storage withdraws, because only TCF records + /// a visitor declining the very signal storage depended on. A US-style + /// opt-out suppresses use for the request and never destroys an identifier, + /// so the other providers leave this at its default. + /// + /// Whether the refusal is destructive at all is core's to decide from the + /// jurisdiction's storage baseline, which is why this answers the narrow + /// question only. It does not consult `tcf_authoritative`, matching the + /// rule as it stood before the seam, where a record refusing storage + /// withdrew whether or not the policy let the record grant anything. + fn withdraws(&self, permission: Permission, input: &SignalInput<'_>) -> bool { + if permission != Permission::StoreOnDevice { + return false; + } + effective_tcf(input.consent).is_some_and(|record| !record.has_storage_consent()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy, under which a TCF record answers. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + fn with_record(consented: &[usize]) -> ConsentContext { + ConsentContext { + tcf: Some(tcf_with_purposes(consented)), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().signal(permission, &input) + } + + fn withdraws(consent: &ConsentContext, policy: &SignalPolicy, permission: Permission) -> bool { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().withdraws(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(TcfProvider::new().id(), ID); + } + + #[test] + fn grants_a_permission_whose_purpose_the_record_consents_to() { + assert_eq!( + answer( + &with_record(&[1]), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Grant, + "Purpose 1 consent grants device storage" + ); + } + + #[test] + fn revokes_a_permission_whose_purpose_the_record_refuses() { + assert_eq!( + answer( + &with_record(&[1]), + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Revoke, + "a purpose the visitor did not consent to is a refusal, not silence" + ); + } + + #[test] + fn is_silent_for_a_data_use_no_purpose_grants() { + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + answer(&with_record(&[1]), shipped_policy(), sale), + ConsentSignal::Neutral, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } + + #[test] + fn is_silent_when_no_record_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent record is silence, never a refusal" + ); + } + + #[test] + fn a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { + // The default policy declares no TCF block, so the record does not + // answer for the deployment. Withdrawal is the narrower, destructive + // question and keeps the rule it had before the seam, which did not + // consult the flag. + let silenced = SignalPolicy::default(); + assert!( + !silenced.tcf_authoritative(), + "the fixture must not be authoritative" + ); + assert_eq!( + answer( + &with_record(&[4]), + &silenced, + Permission::SelectPersonalisedAds + ), + ConsentSignal::Neutral, + "a record the policy does not let answer stays silent" + ); + assert!( + withdraws(&with_record(&[4]), &silenced, Permission::StoreOnDevice), + "but a record refusing storage still withdraws, as it did before the seam" + ); + } + + #[test] + fn withdraws_only_for_storage_and_only_when_refused() { + assert!( + withdraws( + &with_record(&[4]), + shipped_policy(), + Permission::StoreOnDevice + ), + "refusing Purpose 1 withdraws storage" + ); + assert!( + !withdraws( + &with_record(&[1]), + shipped_policy(), + Permission::StoreOnDevice + ), + "consenting to Purpose 1 is not a withdrawal" + ); + assert!( + !withdraws( + &with_record(&[]), + shipped_policy(), + Permission::SelectPersonalisedAds + ), + "no other permission is ever withdrawn, refused or not" + ); + assert!( + !withdraws( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + "and no record is never a withdrawal" + ); + } + + #[test] + fn reads_the_eu_section_of_a_gpp_string_when_there_is_no_standalone_record() { + // Core resolves a GPP string's EU TCF section as the effective record + // when no TC string arrived, and this provider reads what core resolved + // rather than the wire, so it sees that section too. + let consent = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(tcf_with_purposes(&[4])), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + assert_eq!( + answer( + &consent, + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Grant, + "the EU section's consent to Purpose 4 grants targeted advertising" + ); + assert!( + withdraws(&consent, shipped_policy(), Permission::StoreOnDevice), + "and its refusal of Purpose 1 withdraws storage" + ); + } +} diff --git a/crates/permission-signal/tcf/src/mapping.rs b/crates/permission-signal/tcf/src/mapping.rs new file mode 100644 index 000000000..f9cdea45a --- /dev/null +++ b/crates/permission-signal/tcf/src/mapping.rs @@ -0,0 +1,172 @@ +//! Which Data Use each TCF purpose grants. +//! +//! This table lives here, in the crate for the scheme it belongs to, rather +//! than in the policy file core reads. Core does not know what a TCF purpose +//! is, and a deployment that runs no TCF at all should not carry a table of +//! another scheme's numbers in its configuration. +//! +//! It was moved verbatim from the `signals.tcf.purposes` block of the sample +//! policy, so behavior is unchanged for a deployment that never edited that +//! block. A deployment that had edited it now changes this crate instead. +//! +//! # Where this should eventually come from +//! +//! The IAB Privacy Taxonomy is adding a `tcf` column. When that is finalized +//! it becomes the single source for this mapping and the table below is +//! replaced by reading it, rather than being maintained by hand. Until then +//! this is the authority for this crate. + +use trusted_server_core::permissions::Permission; + +/// A TCF purpose number and the Data Use identifiers it grants. +/// +/// Identifiers rather than [`Permission`] values, so the table reads the same +/// as the policy block it came from and can be checked against the taxonomy by +/// eye. +const PURPOSES: &[(u8, &[&str])] = &[ + (1, &["necessary.operations.storage"]), + ( + 2, + &[ + "advertising_marketing.first_party.contextual", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + ], + ), + (3, &["advertising_marketing.profiling"]), + ( + 4, + &[ + "advertising_marketing.first_party.targeted", + "advertising_marketing.third_party.targeted", + ], + ), + (5, &["advertising_marketing.personalize.profiling"]), + ( + 6, + &[ + "advertising_marketing.personalize.content", + "advertising_marketing.personalize.system", + "functional.personalization", + ], + ), + ( + 7, + &[ + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_viewability", + ], + ), + (8, &["analytics.ad_reporting.content_performance"]), + ( + 9, + &[ + "analytics.ad_reporting.market_research", + "analytics.ad_reporting.campaign_insights", + ], + ), + (10, &["necessary.operations.improve"]), + (11, &["select-basic-content"]), +]; + +/// The TCF purpose that grants `permission`, or `None` when no purpose does. +/// +/// A permission no purpose maps to is one TCF has nothing to say about, and +/// the provider answers silence for it rather than a refusal. +/// +/// Compared on the identifier string, so a lookup is at most twenty string +/// comparisons. Resolving each identifier back to a [`Permission`] first would +/// scan the whole taxonomy per row, and this runs for every permission on +/// every request. +#[must_use] +pub fn purpose_for(permission: Permission) -> Option { + let id = permission.as_str(); + PURPOSES + .iter() + .find_map(|(purpose, uses)| uses.contains(&id).then_some(*purpose)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_identifier_in_the_table_is_a_real_data_use() { + // The failure this guards is a typo silently disabling a purpose. A + // name that resolves to nothing would make the purpose grant nothing, + // and no test asserting one specific mapping would notice the ones it + // does not name. + let mut unknown = Vec::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if Permission::from_identifier(id).is_none() { + unknown.push(format!("purpose {purpose}: {id}")); + } + } + } + assert!( + unknown.is_empty(), + "these Data Use identifiers are not in the taxonomy: {unknown:?}" + ); + } + + #[test] + fn no_data_use_is_granted_by_two_purposes() { + // The policy parser used to refuse this as a duplicate. With the table + // in code the check moves here, so a purpose cannot be silently + // shadowed by an earlier row. + let mut seen = std::collections::BTreeMap::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if let Some(first) = seen.insert(*id, *purpose) { + panic!("{id} is granted by purpose {first} and again by purpose {purpose}"); + } + } + } + } + + #[test] + fn the_purposes_the_policy_block_used_to_declare_still_map() { + // The two mappings the old policy parser test pinned, now pinned here. + assert_eq!( + purpose_for(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + purpose_for(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + } + + #[test] + fn a_purpose_granting_several_uses_is_found_from_each_of_them() { + // Purpose 4 grants two Data Uses, and both must resolve back to it. + let first = Permission::from_identifier("advertising_marketing.first_party.targeted") + .expect("should be a known Data Use"); + let third = Permission::from_identifier("advertising_marketing.third_party.targeted") + .expect("should be a known Data Use"); + assert_eq!( + purpose_for(first), + Some(4), + "the first-party Data Use is Purpose 4" + ); + assert_eq!(purpose_for(third), Some(4), "and so is the third-party one"); + } + + #[test] + fn a_data_use_no_purpose_grants_maps_to_nothing() { + // A sale disclosure is a Data Use the taxonomy carries and no TCF + // purpose grants. Silence rather than a refusal is the contract, and + // it starts here. + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + purpose_for(sale), + None, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } +} diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml new file mode 100644 index 000000000..db84aadb1 --- /dev/null +++ b/crates/permission-signal/us-privacy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-us-privacy" +description = "The US Privacy string sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs new file mode 100644 index 000000000..af4cc6c11 --- /dev/null +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -0,0 +1,193 @@ +//! The US Privacy string sale opt-out as a permission signal provider. +//! +//! Answers from the sale opt-out carried in the four character `us_privacy` +//! string, which core decodes into the consent record. Core also constructs +//! that string from a Global Privacy Control header in a US state when the +//! deployment's consent settings say to, and this provider sees the result +//! the same way, because it reads the record and not the wire. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::PrivacyFlag; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "us-privacy"; + +/// A US Privacy string sale opt-out, read from `us_privacy`. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsPrivacyProvider; + +impl UsPrivacyProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for UsPrivacyProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::UsPrivacyOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::UsPrivacy; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_flag(opt_out_sale: PrivacyFlag) -> ConsentContext { + ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + UsPrivacyProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(UsPrivacyProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::No), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a string present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::NotApplicable), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a string saying the flag does not apply is silence" + ); + } + + #[test] + fn is_silent_when_no_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_flag(PrivacyFlag::Yes); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !UsPrivacyProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..b28eda1bf 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,6 +29,10 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } [dev-dependencies] axum = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 90bcac37e..709125b02 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -50,6 +50,31 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, +} + +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] } /// Build the application state, loading settings and constructing all per-application components. @@ -92,11 +117,17 @@ fn build_state_with_settings( ensure_provider_available(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + permission_signal_providers, })) } @@ -149,7 +180,8 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx, &state.settings); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -709,6 +741,9 @@ mod tests { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + // These tests exercise the Edge Cookie provider path, and a + // request with no signal provider resolves at the place baseline. + permission_signal_providers: Arc::default(), } } @@ -728,7 +763,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_runtime_services(&ctx, &state.settings); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); let req = ctx.into_request(); let error = build_ec_context(&state, &services, &req) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 4886bd6e4..f4e9fb6ce 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -536,6 +536,9 @@ impl PlatformHttpClient for AxumPlatformHttpClient { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { @@ -587,6 +590,10 @@ pub fn build_runtime_services( settings, Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs new file mode 100644 index 000000000..99ec15022 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -0,0 +1,476 @@ +//! The four shipped signal providers assembled together, as a deployment +//! runs them. +//! +//! Each provider crate tests its own scheme in isolation, in its own unit +//! tests. What is tested here is what only shows when multiple providers +//! run in order through core's assembly: an answer to a prompt applying +//! over an opt-out, one opt-out standing when another is removed, a scheme +//! left off the list not running at all, and withdrawal being TCF's alone +//! and scoped to the place. This sits in the Axum adapter's tests because +//! it is the first crate that links all four, and core deliberately links +//! none. +//! +//! The consent records here are built by hand, so nothing in core's consent +//! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy +//! opt-out from a Global Privacy Control header in a US state when the consent +//! settings say to, and the `us-privacy` provider then acts on it, which is +//! why removing `gpc` from the list alone does not make that header inert. + +use std::sync::Arc; + +use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; +use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::{ + PermissionSignalProvider, build_permission_signal_providers, +}; +use trusted_server_core::permissions::{Permission, PermissionState}; +use trusted_server_core::platform::GeoInfo; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpc::GpcProvider; +use trusted_server_permission_signal_gpp::GppSaleOptOutProvider; +use trusted_server_permission_signal_tcf::TcfProvider; +use trusted_server_permission_signal_us_privacy::UsPrivacyProvider; + +/// The four providers an adapter offers, in the default order. +fn all_four() -> Vec> { + vec![ + Arc::new(GpcProvider::new()), + Arc::new(GppSaleOptOutProvider::new()), + Arc::new(UsPrivacyProvider::new()), + Arc::new(TcfProvider::new()), + ] +} + +/// The providers a deployment gets from naming these identifiers in +/// `[permission_signal] sources`, through the same entry point an adapter's +/// composition root uses. +fn configured(names: &[&str]) -> Arc<[Arc]> { + let mut settings = Settings::default(); + settings.permission_signal.sources = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + build_permission_signal_providers(&settings, &all_four()) + .expect("should select providers this build offers") +} + +/// Every provider except the one named, in the default order, as a +/// publisher removes one from configuration. +fn all_but(excluded: &str) -> Arc<[Arc]> { + let names: Vec<&str> = all_four() + .iter() + .map(|provider| provider.id()) + .filter(|id| *id != excluded) + .collect(); + configured(&names) +} + +fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::default() +} + +fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + providers: &[Arc], +) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, providers) +} + +/// Builds a minimal decoded TCF record consenting to the given 1-indexed +/// purposes, with everything else refused. +fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } +} + +fn us_privacy_opted_out() -> UsPrivacy { + UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + } +} + +fn gpp_sale_opted_out() -> GppConsent { + GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + } +} + +/// A US opt-out state, where the baseline grants storage without a signal, so +/// a revoke is observable as a drop and a refusal is never a withdrawal. +fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } +} + +// ---------------------------------------------------------------------- +// Which providers run. +// ---------------------------------------------------------------------- + +#[test] +fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline, because the map granted + // these purposes and Global Privacy Control revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); +} + +#[test] +fn a_provider_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every provider running, the header takes storage away" + ); + + let pruned = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the provider that read the header then does not run" + ); +} + +#[test] +fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate providers rather than one. + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); +} + +#[test] +fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(UsPrivacy { + opt_out_sale: PrivacyFlag::No, + ..us_privacy_opted_out() + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out provider should suppress, whatever the others say" + ); +} + +// ---------------------------------------------------------------------- +// Opt-out and prompt precedence. +// +// The providers are asked in order and each amends what the ones before it +// settled, so a later provider can amend an opt-out. The default order asks +// Global Privacy Control first, being a browser setting with no interface of +// its own, and the schemes carrying a choice someone made through an +// interface after, which is why an answer given at a prompt amends the +// header the visitor arrived with. A deployment wanting the opposite puts +// the provider it wants to win last. +// ---------------------------------------------------------------------- + +#[test] +fn a_prompt_answer_applies_over_a_gpc_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn the_opt_out_wins_when_a_deployment_puts_it_last() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let providers = configured(&["tcf", "gpc"]); + let state = assembled(&consent, GeoStatus::Located(&geo), &providers); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the same request, with the order reversed in configuration, lets the header win" + ); +} + +// ---------------------------------------------------------------------- +// The TCF mapping, now the TCF crate's, still reaches every purpose. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // Consent to all purposes except Purpose 7 (measure ad performance), in a + // US opt-out state where the baseline granted them all, so a revoke is + // observable as a drop. + let consented: Vec = (1..=11).filter(|&purpose| purpose != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + assert!( + state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" + ); +} + +// ---------------------------------------------------------------------- +// Withdrawal scoping: only a TCF storage refusal withdraws, and only where +// the baseline did not grant storage outright. Opt-outs suppress use but +// never destroy an already-issued identifier. +// +// No location resolves at the policy's top node, the gdpr-eu group, where +// storage requires a signal. A US opt-out state grants it outright. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + state.storage_withdrawn(), + "refusing the signal storage depends on should withdraw" + ); +} + +#[test] +fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.storage_withdrawn(), + "storage never depended on the record here, so refusal suppresses without destroying" + ); +} + +#[test] +fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "a consenting record is not a withdrawal" + ); +} + +#[test] +fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_four()).storage_withdrawn() + && !assembled(&consent, GeoStatus::NoLocation, &all_four()).storage_withdrawn(), + "GPC suppresses use for the request but never destroys the identifier" + ); +} + +#[test] +fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "sale opt-outs suppress use but never destroy the identifier" + ); +} + +#[test] +fn no_signal_is_not_a_withdrawal() { + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &all_four(), + ); + assert!( + !state.storage_withdrawn(), + "absence of a signal must never destroy an identifier" + ); +} + +#[test] +fn a_withdrawal_needs_the_tcf_provider_to_be_running() { + // The withdrawal is TCF's answer, so a deployment that removed the TCF + // provider from the list has no scheme left that can withdraw. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_but("tcf")); + assert!( + !state.storage_withdrawn(), + "a scheme that does not run cannot withdraw, whatever the request carries" + ); +} + +// ---------------------------------------------------------------------- +// Unreadable and expired records, assembled with the real providers. +// ---------------------------------------------------------------------- + +#[test] +fn a_malformed_tcf_record_blocks_baseline_grants() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + assert!( + !state.storage_withdrawn(), + "and it fails closed by suppression, never destructively" + ); +} + +#[test] +fn a_readable_tcf_record_does_not_cure_an_unreadable_gpp_string() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "one scheme arriving unreadable is not cured by another scheme arriving readable" + ); +} + +#[test] +fn an_expired_tcf_record_is_not_treated_as_malformed() { + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..0bea7d5f7 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -30,6 +30,10 @@ futures = { workspace = true } log = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } worker = { workspace = true, optional = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index fad5e65a4..2da4f4f24 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -67,6 +67,12 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -135,15 +141,40 @@ fn build_state_with_settings( let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -153,7 +184,7 @@ fn build_state_with_settings( /// `[ec] provider` a second time. Nothing is carried when the composition root /// found nothing safe to keep, and the request path resolves for itself. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx, &state.settings) + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) .with_resolved_ec_provider(state.ec_provider.clone()) } @@ -721,7 +752,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services(&ctx, &settings, &Arc::default()); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 7bdeefe30..5bcf2b855 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -601,6 +601,9 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -647,6 +650,10 @@ pub fn build_runtime_services( .backend(Arc::new(NoopBackend)) .http_client(http_client) .geo(geo) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index d0a43e670..d7f2c2fb3 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -29,6 +29,10 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-device-fastly = { workspace = true } trusted-server-geo-fastly = { workspace = true } url = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 2445ca467..40c28bb0a 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -176,6 +176,12 @@ pub(crate) struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. pub(crate) ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + pub(crate) permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -229,6 +235,11 @@ pub(crate) fn build_state_from_settings( let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -240,9 +251,29 @@ pub(crate) fn build_state_from_settings( default_kv_store, auction_telemetry_sink, ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + fn warn_if_certificate_check_disabled(settings: &Settings) { if !settings.proxy.certificate_check { log::warn!( @@ -341,6 +372,10 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(&state.permission_signal_providers)) .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); // Hand every request the provider resolved at the composition root, so the @@ -1646,6 +1681,9 @@ mod tests { registry: Arc::new(registry), default_kv_store, ec_provider, + // These tests exercise routing, and a request with no signal + // provider resolves at the place baseline. + permission_signal_providers: Arc::default(), }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 77c4139bc..a0a5887a2 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,6 +32,10 @@ futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 5ce3fb300..22d648aec 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -63,6 +63,12 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -107,15 +113,40 @@ fn build_state_with_settings( let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -541,7 +572,7 @@ impl TrustedServerApp { /// composition root already resolved so the request path does not resolve /// `[ec] provider` a second time. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx, &state.settings) + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) .with_resolved_ec_provider(state.ec_provider.clone()) } @@ -1005,7 +1036,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services(&ctx, &settings, &Arc::default()); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 642c82b87..3d00f11d6 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -763,6 +763,9 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -799,6 +802,10 @@ pub fn build_runtime_services( settings, Arc::new(NullGeo), )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1048,8 +1055,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = - build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); + let services = build_runtime_services( + &ctx, + &trusted_server_core::settings::Settings::default(), + &Arc::default(), + ); assert!( services.client_info().client_ip.is_none(), From 48919be5a8ee5d4b5db6dfcdea66be19eefe6109 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:16 +0100 Subject: [PATCH 085/133] Document permission signals and the provider order A guide page for the seam and the four shipped providers, in the site's sidebar, covering why the providers are not in core, how a request resolves, that the order is the policy and what the default order does, withdrawal as a separate question, and how a scheme is added. The permission model guide is corrected where it still described the TCF mapping as living in permissions.yaml or an opt-out as always winning over a consenting TCF record, and the example configuration now calls the entries providers and says the TCF purpose mapping is in the crate. --- docs/.vitepress/config.mts | 1 + docs/guide/permission-model.md | 62 ++++++++---- docs/guide/permission-signals.md | 166 +++++++++++++++++++++++++++++++ trusted-server.example.toml | 37 +++---- 4 files changed, 227 insertions(+), 39 deletions(-) create mode 100644 docs/guide/permission-signals.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 99c60cdba..80f483de6 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -80,6 +80,7 @@ export default withMermaid( { text: 'Edge Cookies', link: '/guide/edge-cookies' }, { text: 'EC Setup Guide', link: '/guide/ec-setup-guide' }, { text: 'Permission Model', link: '/guide/permission-model' }, + { text: 'Permission Signals', link: '/guide/permission-signals' }, { text: 'GDPR Compliance', link: '/guide/gdpr-compliance' }, { text: 'Ad Serving', link: '/guide/ad-serving' }, { diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 9f2b433c6..9355ad62c 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -33,9 +33,11 @@ onto them where no Data Use exists yet. That matters for reading the rest of this guide. When a provider declares the permissions its data use requires, it is naming a Data Use from that taxonomy, so an operator or an auditor can check the declaration against the taxonomy -rather than against our interpretation of it. The mapping from TCF purposes is -recorded in `permissions.yaml` alongside the rules, so no signal-to-permission -policy is hidden in code. +rather than against our interpretation of it. What a deployment decides about a +signal, whether a TCF record answers and what an opt-out takes away, is recorded +in `permissions.yaml` alongside the rules. What a scheme's own signal means, such +as which TCF purpose grants which Data Use, belongs to that scheme's +[permission signal provider](./permission-signals) and is tested there. ## Separating legal policy from the core @@ -93,8 +95,11 @@ is one source among many, not the basis for every permission: country is identified, or the country has no rule either, the baseline at the top of the rules tree applies. That top baseline is required, so there is always one. -- **Consent signals.** TCF, GPP, or GPC decoded from the request, mapped onto - permissions as a grant or a revoke on top of the baseline. +- **Consent and privacy signals.** TCF, GPP, GPC or a US Privacy string read + from the request, mapped onto permissions as a grant or a revoke on top of + the baseline. Each is answered by a [permission signal + provider](./permission-signals), a crate outside the core, asked in the + order configuration gives. - **Interaction with the user.** A publisher may establish a preference because it chooses to, not only because a law requires it. - **Data from another source.** For example a browser extension, or a person's @@ -122,15 +127,20 @@ Europe purposes and used **only** as technical identifiers. No CMP or TCF policy is implemented in the core. Two purposes have no Data Use yet. Purpose 1 (device storage) uses a proposed `necessary.operations.storage` key, and purpose 11 keeps its TCF identifier `select-basic-content`. Both are flagged for an upstream -taxonomy addition. All eleven purposes are now resolved against the incoming +taxonomy addition. All eleven purposes are resolved against the incoming consent and privacy signals. A present TCF record grants or revokes each purpose directly, and a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy -opt-out) revokes whether or not a TCF record is present. The remaining taxonomy Data Uses +opt-out) revokes the Data Uses the policy lists, each answered by its own +provider in the order configuration gives, so which of them stands when they +disagree is that order. The remaining taxonomy Data Uses have no TCF purpose, so no signal maps to them and their configured baseline -stands. The mapping itself, which TCF purpose grants which Data Use and what a -US-style opt-out revokes, is declared in the `signals` section of -`permissions.yaml`, not in the code, so a deployer changes policy by editing that -file. +stands. What a US-style opt-out revokes, and whether a TCF record answers for +the deployment at all, are declared in the `signals` section of +`permissions.yaml`, so a deployer changes that policy by editing the file. +Which TCF purpose grants which Data Use is not policy but the TCF scheme's own +meaning, so it lives in the TCF [permission signal +provider](./permission-signals) crate, and the core carries no table of another +scheme's numbers. `permissions.yaml` carries a policy flag for **every** Data Use in the taxonomy, not only the eleven below. The eleven have a dedicated identifier because a @@ -139,7 +149,8 @@ where no informed policy decision has been made, is `denied` by default. Trusted Server is not the policy authority, so a deployer sets the flags to match its own jurisdiction rules. -The eleven named Data Uses, with the TCF purpose each maps from: +The eleven named Data Uses, with the TCF purpose each maps from, as the TCF +provider crate maps them: | # | Data Use identifier | IAB TCF Europe purpose | | --- | ----------------------------------------------- | ----------------------------------------------- | @@ -337,16 +348,23 @@ unset otherwise. The Edge Cookie provider runs only when every permission it requires is set, which is the one place a declaration currently decides whether a provider runs. -Signal precedence is fixed in code, most restrictive first. A US-style opt-out -(GPC, a GPP sale opt-out, or a US Privacy opt-out) suppresses the Data Uses -the policy revokes even when a TCF record consents, because an explicit -opt-out is never overridden by another signal. A consent record that is -present but cannot be decoded blocks baseline grants (fail-closed) rather -than degrading to the no-signal baseline. Only then does a TCF record decide -the Data Uses its purposes map to. Opt-outs suppress use for the request; -they never destroy an already-issued identifier. Destructive withdrawal (the -cookie expired and the identity-graph row tombstoned) happens only when a TCF -record refuses storage in a jurisdiction whose baseline did not grant it. +A consent record that is present but cannot be decoded blocks baseline grants +(fail-closed) rather than degrading to the no-signal baseline, ahead of every +signal provider and whichever are configured. The providers are then asked in +the order `[permission_signal] sources` gives, each amending what the ones +before it settled, and the last with an opinion decides. So which of a US-style +opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) and a consenting TCF +record stands when they disagree is the configured order, not a rule in code. +The default order asks Global Privacy Control first, because it is a browser +setting with no interface of its own, and the schemes carrying a choice +someone made through an interface after, so an answer given at a prompt +amends the header the visitor arrived with, and a deployment wanting the +opposite puts the provider it wants to win last. See +[Permission Signals](./permission-signals). Opt-outs suppress use for the +request and never destroy an already-issued identifier. Destructive +withdrawal (the cookie expired and the identity-graph row tombstoned) happens +only when a TCF record refuses storage in a jurisdiction whose baseline did not +grant it. ```mermaid flowchart TD diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md new file mode 100644 index 000000000..36a8f6af1 --- /dev/null +++ b/docs/guide/permission-signals.md @@ -0,0 +1,166 @@ +# Permission Signals + +The [permission model](./permission-model) decides whether a permission is +set. This page is about where the signals that amend it come from, and why +none of them is built into the core. + +## Providers, not a built-in list + +A permission signal provider answers for one scheme. Global Privacy Control is +one, the sale opt-out carried in a GPP string is another, a US Privacy string +a third, and a TCF v2 record a fourth. Each is a crate that depends on the +core, implements one trait, and is linked by the adapter that runs it. The +core holds the trait, the order the providers are asked in, the country and +region baseline, and the policy vocabulary for what a deployment decides about +the shipped schemes, being whether a TCF record answers, which signals count as +a US-style opt-out and what an opt-out takes away. It holds no scheme's wire +format and no scheme's meaning. + +That is deliberate. Privacy is a non-price factor of competition. Publishers, +browsers and standards bodies compete on it, and schemes come and go. +Compiling a closed list of schemes into the core would settle that competition +in code, because the schemes built in would be the only ones a deployment +could act on, and the core maintainers would be deciding which privacy schemes +exist. So the four that ship are crates like any other, none of them +privileged by being the one that happens to be built in, and a scheme the core +has never heard of plugs in the same way. + +The core also does not know what a TCF purpose is. The mapping from TCF +purpose to Data Use lives in the TCF provider crate, so a deployment that runs +no TCF carries no table of another scheme's numbers. The purposes are the IAB +TCF Europe purposes and what they grant are IAB Tech Lab Privacy Taxonomy Data +Uses, so a reader checks the mapping against the industry's own documents +rather than against us, and the [permission model](./permission-model) guide +lists it in full. Two purposes have no Data Use yet, so the crate carries a +proposed key for one and the TCF identifier for the other until the taxonomy +adds them. + +A scheme that is not one of the four is added the same way, as a crate that +reads its own signal from the request, without a change to the core. The next one +is Model Terms for Marketing (MTM), where a publisher and the parties it passes +data to agree to be bound by a published set of terms, and what a provider +reads is whether that agreement covers this request. MTM arrives in a following +pull request, so the four here are a starting set and not the list. + +## How a request resolves + +Permissions are resolved in layers, each amending the one before. + +```mermaid +flowchart TD + B["Country and region rules
(the baseline: granted, requires signal, denied)"] --> P1 + P1["Provider 1, in configured order
may amend"] --> P2 + P2["Provider 2
may amend"] --> PN + PN["..."] --> S["The permission state for this request"] +``` + +The baseline comes from `permissions.yaml`, keyed by country and region, with +the top node of the rules tree standing in for a request whose place is +unknown. The geo provider supplies the place. No geo provider means no country, +so every request resolves at that top node, and only a lookup that failed +resolves at the requires-signal floor, because a place that could not be +determined must not be treated as the declared default. + +The providers are then asked in order. Each sees what the providers before it +settled on and may amend it. A provider with no opinion answers neutral and +leaves the prior value standing, which is different from refusing. + +## The order is the policy + +The last provider with an opinion decides, so the order is the policy. It is a +deployment's to set, not the code's to assume. + +```toml +[permission_signal] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +``` + +A provider not on the list does not run, and there is no separate switch. A +publisher who does not want to act on Global Privacy Control removes `"gpc"` +from the list, and the provider that reads the header then does not run. One +caveat: the core's consent pipeline can also synthesize a US Privacy opt-out +from that header for a visitor in a US state, when the consent settings say to, +which they do by default, and the `us-privacy` provider then acts on the record +it produced. A publisher who wants the header to have no effect at all turns +that setting off as well. Leaving the section out entirely runs every provider +the adapter offers, in the order it offers them, so a signal is never quietly +ignored because someone forgot to list it. An empty list runs none of them, +which is a publisher acting on no signal at all, and leaves every permission +at its country and region baseline. + +A name matching no provider the adapter links, or a name given twice, is +refused at startup rather than ignored, so a typo cannot silently stop a +scheme being honored. What ran, and what was left out, is written to the log +once at startup. + +The default order asks the signal with no interface of its own first and the +ones carrying a choice made through an interface after. Global Privacy +Control is a browser setting, so it revokes personalization on arrival, +whereas a GPP sale opt-out, a US Privacy string and a TCF record each carry +an answer a person gave, so they are asked later and amend it. A deployment +wanting the browser setting to stand over a later answer puts `gpc` last. +Trusted Server takes no view on which scheme should win, because that is a +question about a jurisdiction and a publisher. + +## The providers that ship + +| Identifier | Crate | Reads | +| ------------------ | ------------------------------------- | ------------------------------------------------- | +| `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp-sale-opt-out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | +| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | + +The three opt-outs are separate so that a publisher who does not act on Global +Privacy Control can remove it and keep the other two. What an opt-out takes +away, and whether a TCF record answers for the deployment at all, remain the +policy's decisions in the `signals` section of `permissions.yaml`, so a +deployment changes those without changing a provider. + +## Withdrawal is a separate question + +A provider may also say that the request explicitly withdraws a permission, +which is different from not granting it. A withdrawal of storage expires the +browser cookie and writes the authoritative tombstone against the identifier. +A permission that is merely not set strips the response headers and leaves an +already issued identifier alone, so a returning visitor is not permanently +withdrawn before they get to answer. + +Most schemes have no such notion, a browser setting and a sale opt-out +included, and of the four that ship only TCF answers it. The core then scopes +the answer to the jurisdiction, so a refusal only withdraws where the storage +baseline did not grant storage outright, because where it did the identifier +never depended on the record. + +## What is not a provider + +A consent record that arrives and cannot be read revokes, ahead of the +providers and whichever of them are configured. That is error handling rather +than a signaling scheme, so it is not in the list and cannot be removed. A +publisher chooses which signals to act on, but not what happens when one of +those signals arrives unreadable. An unreadable record is a preference +someone expressed that could not be read, which is not the same as no +record at all, so it must not degrade to the no-signal baseline. + +## Adding a scheme + +1. Create a crate that depends on `trusted-server-core` and implements + `PermissionSignalProvider` from `trusted_server_core::permission_signal`. + Give it a stable identifier, which is the name configuration uses. +2. Answer neutral for a permission the scheme has no opinion on, including + when its signal is absent from the request. Reading an absent signal as a + refusal would revoke the permission on every request that did not carry + the scheme, which is most of them. +3. Read the request through the evidence the seam offers, which is the + headers, cookies, path and query, so the core keeps no list of what a + scheme may read. The decoded consent record is offered too, for the + schemes the consent pipeline already decodes, caches against the + identifier and expires, and a provider for one of those should read the + record rather than the wire, so it answers the same as every other reader + of the same request. +4. Link the crate at the adapter's composition root, in the list of providers + it offers, and give it a place in the default order. + +The trait, the input a provider receives, and the rules for consulting a peer +are documented in the module itself, at +`crates/trusted-server-core/src/permission_signal/README.md`. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e50e94b40..b2fcda84e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -203,41 +203,44 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 -# Which permission signal models run, and in what order. +# Which permission signal providers run, and in what order. # # Signals compose rather than select. A request can carry a TCF string and a # Global Privacy Control header at once, and both have something to say, so -# this is a list where [ec], [geo] and [device] each name one provider. The -# order is the policy, because the last source with an opinion decides. +# this is a list, whereas [ec], [geo] and [device] each name one provider. The +# order is the policy, because the last provider with an opinion decides. # -# Listed below is every model this build knows about, which is also exactly -# what runs when this section is absent. Remove the ones this publisher does -# not want to act on. A model that is not on the list does not run, and there -# is no separate switch to turn one off. An unknown or repeated name is -# refused at startup rather than quietly ignored. +# Listed below is every provider the shipped adapters link, which is also +# exactly what runs when this section is absent. Each is a crate under +# crates/permission-signal, outside the core. Remove the ones this publisher +# does not want to act on. A provider that is not on the list does not run, +# and there is no separate switch to turn one off. An unknown or repeated name +# is refused at startup rather than quietly ignored. # # [permission_signal] # sources = [ # "gpc", # the Sec-GPC request header, Global Privacy Control # "gpp-sale-opt-out", # a GPP US sale opt-out # "us-privacy", # a US Privacy string sale opt-out -# "tcf", # TCF v2 +# "tcf", # TCF v2, with its purpose mapping in the crate # ] # # The three opt-outs are separate entries so that a publisher who does not act # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. # -# A consent record that arrives and cannot be read revokes, whichever models -# are configured. That is error handling rather than a model to choose, so it -# is not in this list and cannot be removed. +# A consent record that arrives and cannot be read revokes, whichever providers +# are configured. That is error handling rather than a provider to choose, so +# it is not in this list and cannot be removed. # -# The default order asks the signals needing no interaction before the ones -# that follow a prompt, so a visitor who arrives with an opt-out and then -# answers a prompt has their answer applied. Reorder the list to change that. +# The default order asks Global Privacy Control first, being a browser setting +# with no interface of its own, and the three that carry a choice someone made +# through an interface after, so an answer given at a prompt amends the header +# the visitor arrived with. Reorder the list to change that. # -# The layering and what a model may consult are documented in -# crates/trusted-server-core/src/permission_signal/README.md. +# The layering, what a provider may consult, and how to add a scheme are +# documented in crates/trusted-server-core/src/permission_signal/README.md +# and in the Permission Signals guide. # Proxy behavior and first-party asset routing. Kept active with defaults. [proxy] From b752d960378e58d670859f375cda1e7327c1702f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 08:57:17 +0100 Subject: [PATCH 086/133] Carry the terms the data is available under A permission says what may be done with the data. It does not say on what basis, and a recipient offered data needs both, because it has to decide whether the terms are ones it accepts and whether it may pass the data on. So a provider may now declare the terms documents that cover the request, core carries what every configured provider declared on the permission state, and the page reads them as `tdls` beside `set`. A locator is the address of a published document a person can read. The document must never be edited once published, which is why a version belongs in its address, because a document that can be rewritten tomorrow means a recipient can never prove what it agreed to and one edit silently rewrites the basis of every transaction already sent under it. That is a property of how a document is published rather than of its address, so the type refuses only an address nothing could fetch, and says why in its own documentation. The name matches the `tdl` member the Data Labels work puts on a node of an OpenRTB request, which is where these travel when a bid request carries them. None of the four schemes here declares terms, so the list is empty in every shipped path and the tests use a provider that declares one, through the production assembly rather than beside it. Model Terms for Marketing (MTM) is the first scheme that will declare terms and one of many rather than the only one, since a publisher, a trade body or a regulator can each publish terms and each set becomes a provider. An empty list says no terms were declared, which is not the same as terms permitting anything, so a recipient needing a basis and finding none has none. That is the reason the state carries the list at all rather than leaving a reader to assume. The permission state stops being `Copy`, because it now owns a list whose length varies. One test helper copied it and now clones it; nothing else in the workspace was relying on the copy. --- crates/trusted-server-core/src/ec/consent.rs | 59 +++++- .../src/integrations/registry.rs | 6 +- crates/trusted-server-core/src/lib.rs | 1 + .../src/permission_signal/README.md | 28 ++- .../src/permission_signal/mod.rs | 134 ++++++++++++ crates/trusted-server-core/src/permissions.rs | 85 ++++++-- crates/trusted-server-core/src/tdl.rs | 191 ++++++++++++++++++ .../lib/src/core/permissions.ts | 21 +- .../trusted-server-js/lib/src/core/types.ts | 8 + .../lib/test/core/permissions.test.ts | 56 ++++- docs/guide/permission-model.md | 4 +- docs/guide/permission-signals.md | 33 ++- 12 files changed, 591 insertions(+), 35 deletions(-) create mode 100644 crates/trusted-server-core/src/tdl.rs diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index e0a6ee4e0..67028d86f 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -99,7 +99,9 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { /// empty slice runs none of them, which leaves every permission at its /// country and region baseline. The same providers answer whether storage /// was explicitly withdrawn, recorded on the state and read through -/// [`PermissionState::storage_withdrawn`]. +/// [`PermissionState::storage_withdrawn`], and declare the terms documents the +/// request's data is available under, read through +/// [`PermissionState::tdls`]. #[must_use] pub fn assemble_permissions( consent: &ConsentContext, @@ -128,7 +130,9 @@ pub fn assemble_permissions( maps.signals(), storage_acquisition(geo), ); - state.with_storage_withdrawn(withdrawn) + state + .with_storage_withdrawn(withdrawn) + .with_tdls(permission_signal::tdls(providers, consent, evidence)) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -224,6 +228,32 @@ mod tests { } } + /// A provider that declares a terms document, standing in for a terms + /// scheme such as Model Terms for Marketing, which is the next provider + /// and is not one of the four that ship here. + struct DeclaringTerms; + + impl PermissionSignalProvider for DeclaringTerms { + fn id(&self) -> &'static str { + "declaring-terms" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls( + &self, + _consent: &ConsentContext, + _evidence: &dyn crate::evidence::RequestInfo, + ) -> Vec { + vec![ + crate::tdl::Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ] + } + } + fn no_evidence() -> OwnedRequestInfo { OwnedRequestInfo::new(String::new(), HeaderMap::new()) } @@ -253,6 +283,31 @@ mod tests { } } + #[test] + fn a_provider_declaring_terms_reaches_the_assembled_state() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![Arc::new(DeclaringTerms)]; + let state = + assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &providers); + let addresses: Vec<&str> = state.tdls().iter().map(crate::tdl::Tdl::as_str).collect(); + assert_eq!( + addresses, + vec!["https://terms.example.com/marketing/2.txt"], + "should carry the terms the provider declared through to whatever reads the state" + ); + } + + #[test] + fn a_state_assembled_from_the_shipped_kind_of_provider_declares_no_terms() { + let consent = ConsentContext::default(); + let state = + assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &granting()); + assert!( + state.tdls().is_empty(), + "should declare nothing, because a scheme carrying no terms says nothing about them" + ); + } + #[test] fn hmac_provider_is_blocked_without_a_storage_signal() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fb9a3dd91..72fcb2404 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1491,10 +1491,10 @@ mod tests { /// The permission state observed by the last invocation, or `None` when /// the filter has not run. fn seen(&self) -> Option> { - *self - .seen + self.seen .lock() .expect("should lock the recorded permission state") + .clone() } } @@ -1512,7 +1512,7 @@ mod tests { .seen .lock() .expect("should lock the recorded permission state") = - Some(input.permissions.copied()); + Some(input.permissions.cloned()); Ok(RequestFilterDecision::Continue( RequestFilterEffects::default(), )) diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 47c0d5e85..f5dbaca69 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -72,6 +72,7 @@ pub mod settings_data; pub mod storage; pub mod streaming_processor; pub mod streaming_replacer; +pub mod tdl; pub mod test_support; pub mod tester_cookie; pub mod tsjs; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 888385777..1bfad5f5b 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -39,7 +39,9 @@ reads its own signal from the request, without a change to core. The next one is Model Terms for Marketing (MTM), where a publisher and the parties it passes data to agree to be bound by a published set of terms, and what a provider reads is whether that agreement covers this request. MTM arrives in a following -pull request, so the four here are a starting set and not the list. +pull request, and it is one of many terms schemes rather than the only one, +because a publisher, a trade body or a regulator can each publish terms and +each set becomes a provider. The four here are a starting set and not the list. ## The hierarchy @@ -119,6 +121,30 @@ information available. Consulting a peer goes one level deep. A provider answering a consultation cannot consult in turn, so two providers asking each other cannot loop. +## The terms the data is available under + +A provider may also declare the terms documents the request's data is available +under, through `tdls` on the trait, and core carries what every configured +provider declared on the permission state. Whoever receives the data reads them +to decide whether those are terms they accept, and whether they may pass the +data on. No declaration means no terms were declared, which is not the same as +terms permitting anything, so a recipient needing a basis and finding none has +none. + +Each entry is the address of a published document a person can read, and the +document must never be edited once published, which is why a version belongs in +its address. A document that can be rewritten tomorrow means a recipient can +never prove what it agreed to, and one edit silently rewrites the basis of every +transaction already sent under it. That is a property of how the document is +published, so the [`Tdl`](crate::tdl::Tdl) type refuses only an address nothing +could fetch. + +None of the four schemes here carries terms, so the list is empty until a terms +scheme runs, Model Terms for Marketing (MTM) being the first of many rather than +the only one. The name matches the `tdl` member the Data Labels work puts on a +node of an `OpenRTB` request, which is where these travel once a bid request +carries them. + ## Withdrawal is a separate question A provider may also say that the request explicitly *withdraws* a permission, diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 19686dfe6..0decf789a 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -9,6 +9,7 @@ use crate::error::TrustedServerError; use crate::evidence::RequestInfo; use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; use crate::settings::Settings; +use crate::tdl::Tdl; /// What a signal provider may read about a request. /// @@ -167,6 +168,25 @@ pub trait PermissionSignalProvider: Send + Sync { fn withdraws(&self, _permission: Permission, _input: &SignalInput<'_>) -> bool { false } + + /// The terms documents this provider says the request's data is + /// available under, empty when it declares none. + /// + /// A locator tells whoever receives the data what terms cover it, so + /// they can decide whether those are terms they accept and whether they + /// may pass the data on. The four schemes that ship carry no terms of + /// their own and leave this at its default, and a provider for a terms + /// scheme returns the document that applies to this request. Model Terms + /// for Marketing (MTM) is the first such scheme and one of many rather + /// than the only one. Core does + /// not read the documents, it carries the locators, so what a document + /// says stays between the parties bound by it. + /// + /// A locator must point at a document that is never edited once + /// published, which [`Tdl`] documents and cannot enforce. + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + Vec::new() + } } /// Asks every provider in order and returns what they settle on together. @@ -244,6 +264,29 @@ pub(crate) fn withdrawn( }) } +/// The terms documents the configured providers declare for this request. +/// +/// Asked in the same order the providers answer in, so the list reads the +/// way the deployment is configured, and a document named by two providers +/// is carried once. No provider declaring anything leaves the list empty, +/// which says no terms were declared rather than that any terms apply. +#[must_use] +pub(crate) fn tdls( + providers: &[Arc], + consent: &ConsentContext, + evidence: &dyn RequestInfo, +) -> Arc<[Tdl]> { + let mut declared: Vec = Vec::new(); + for provider in providers { + for tdl in provider.tdls(consent, evidence) { + if !declared.contains(&tdl) { + declared.push(tdl); + } + } + } + Arc::from(declared) +} + /// The providers a deployment named, in the order it named them, drawn from /// the ones the build makes available. /// @@ -402,6 +445,24 @@ mod tests { } } + /// A provider that declares a terms document, which is what a scheme like + /// Model Terms for Marketing does and none of the four that ship do. + struct Declaring(&'static str, &'static str); + + impl PermissionSignalProvider for Declaring { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + vec![Tdl::new(self.1).expect("should accept the test locator")] + } + } + /// A provider that withdraws storage, for testing the scoping rule. struct Withdrawing; @@ -427,6 +488,79 @@ mod tests { Arc::new(Fixed(id, signal)) } + #[test] + fn a_provider_declaring_no_terms_leaves_the_list_empty() { + let consent = ConsentContext::default(); + let declared = tdls( + &[fixed("quiet", ConsentSignal::Grant)], + &consent, + &no_evidence(), + ); + assert!( + declared.is_empty(), + "should declare nothing, because the four shipped schemes carry no terms" + ); + } + + #[test] + fn terms_are_collected_in_the_order_the_providers_are_asked() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + fixed("quiet", ConsentSignal::Neutral), + Arc::new(Declaring("second", "https://terms.example.com/b/1.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + let addresses: Vec<&str> = declared.iter().map(Tdl::as_str).collect(); + assert_eq!( + addresses, + vec![ + "https://terms.example.com/a/1.txt", + "https://terms.example.com/b/1.txt" + ], + "should read in the configured order, so the list matches the deployment" + ); + } + + #[test] + fn one_document_named_by_two_providers_is_carried_once() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/1.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 1, + "should carry the same document once, not once per provider naming it" + ); + } + + #[test] + fn versions_of_one_document_are_both_carried() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/2.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 2, + "should keep both, because a recipient agreed to one version and not the other" + ); + } + + #[test] + fn no_providers_declare_nothing() { + let consent = ConsentContext::default(); + assert!( + tdls(&[], &consent, &no_evidence()).is_empty(), + "should declare nothing when no provider runs, rather than implying terms" + ); + } + fn combined(providers: &[Arc]) -> ConsentSignal { let consent = ConsentContext::default(); let policy = SignalPolicy::default(); diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index ea6445152..b888a47fd 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -37,12 +37,13 @@ //! reports one. use std::collections::BTreeMap; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use serde::Deserialize; use serde_yaml_ng::Value; use crate::consent::jurisdiction::Jurisdiction; +use crate::tdl::Tdl; /// A technical permission a provider may require, labeled with its IAB Privacy /// Taxonomy Data Use, or its IAB TCF Europe purpose where no Data Use exists yet. @@ -762,13 +763,17 @@ impl PermissionMaps { /// /// A provider executes only when [`all_set`](Self::all_set) of its required /// permissions returns `true`. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PermissionState { set: PermissionSet, /// Whether the request explicitly withdrew device storage, as opposed to /// storage merely not being set. See /// [`storage_withdrawn`](Self::storage_withdrawn). storage_withdrawn: bool, + /// The terms documents the data for this request is available under. See + /// [`tdls`](Self::tdls). Shared rather than owned because the state is + /// cloned along the request path and the list is the same list. + tdls: Arc<[Tdl]>, } impl PermissionState { @@ -776,10 +781,11 @@ impl PermissionState { /// nothing is withdrawn, for tests and callers that compute the set /// directly. #[must_use] - pub const fn new(set: PermissionSet) -> Self { + pub fn new(set: PermissionSet) -> Self { Self { set, storage_withdrawn: false, + tdls: Arc::default(), } } @@ -787,13 +793,33 @@ impl PermissionState { /// withdrawn. Set by assembly from what the signal providers answered, /// scoped to the jurisdiction's storage baseline. #[must_use] - pub const fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { + pub fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { Self { storage_withdrawn, ..self } } + /// The same state, carrying the terms documents the data for this request + /// is available under. Set by assembly from what the signal providers + /// declared, in the order they are asked. + #[must_use] + pub fn with_tdls(self, tdls: Arc<[Tdl]>) -> Self { + Self { tdls, ..self } + } + + /// The terms documents the data for this request is available under, in + /// the order the providers were asked. + /// + /// Whoever receives the data reads these to decide whether the terms are + /// ones they accept, and whether they may pass the data on. An empty list + /// says no terms were declared, which is not the same as terms that permit + /// anything, so a recipient that needs a basis and finds none has none. + #[must_use] + pub fn tdls(&self) -> &[Tdl] { + &self.tdls + } + /// Whether the request carries an explicit signal withdrawing device /// storage, rather than merely lacking the permission. /// @@ -834,9 +860,12 @@ impl PermissionState { /// /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the /// same state always serializes to the same bytes whatever order the set - /// was built in. An empty state - /// renders as `{"set":[]}`, which is an answer (nothing is set) rather than - /// a missing value, so page code never has to tell the two apart. + /// was built in. `tdls` carries the terms documents the data is available + /// under, in the order the providers were asked, so a page module reads the + /// terms alongside the permissions. An empty state renders as + /// `{"set":[],"tdls":[]}`, and both are answers (nothing is set, no terms + /// were declared) rather than missing values, so page code never has to + /// tell the two apart. /// /// This is the only place the page shape is spelled, so no caller writes /// the JSON by hand. @@ -853,16 +882,20 @@ impl PermissionState { /// ); /// assert_eq!( /// state.page_json(), - /// r#"{"set":["necessary.operations.storage"]}"# + /// r#"{"set":["necessary.operations.storage"],"tdls":[]}"# /// ); /// - /// assert_eq!(PermissionState::default().page_json(), r#"{"set":[]}"#); + /// assert_eq!( + /// PermissionState::default().page_json(), + /// r#"{"set":[],"tdls":[]}"# + /// ); /// ``` #[must_use] pub fn page_json(&self) -> String { let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); names.sort_unstable(); - serde_json::json!({ "set": names }).to_string() + let tdls: Vec<&str> = self.tdls.iter().map(Tdl::as_str).collect(); + serde_json::json!({ "set": names, "tdls": tdls }).to_string() } } @@ -1369,13 +1402,39 @@ mod tests { "set": [ "advertising_marketing.first_party.contextual", "necessary.operations.storage", - ] + ], + "tdls": [], }) .to_string(), "should list every set permission by Data Use name, sorted" ); } + #[test] + fn page_json_carries_the_terms_the_data_is_available_under() { + // Arrange: the state a request resolves to when a terms scheme + // declared the document its data is offered under. + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) + .with_tdls(Arc::from(vec![ + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ])); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": ["necessary.operations.storage"], + "tdls": ["https://terms.example.com/marketing/2.txt"], + }) + .to_string(), + "page code should read the terms alongside the permissions" + ); + } + #[test] fn page_json_of_an_empty_state_is_an_empty_set() { // Arrange @@ -1387,8 +1446,8 @@ mod tests { // Assert assert_eq!( json, - json!({ "set": [] }).to_string(), - "an empty state should render as an empty set, not as nothing" + json!({ "set": [], "tdls": [] }).to_string(), + "an empty state should render as an empty set and no declared terms, not as nothing" ); } diff --git a/crates/trusted-server-core/src/tdl.rs b/crates/trusted-server-core/src/tdl.rs new file mode 100644 index 000000000..a2202cb83 --- /dev/null +++ b/crates/trusted-server-core/src/tdl.rs @@ -0,0 +1,191 @@ +//! Terms Document Locators, the labels saying what terms the data offered for +//! a request is available under. +//! +//! A locator is the address of a published document a person can read, stating +//! the basis on which the data at hand may be collected, shared and used. A +//! recipient reads the locators alongside the data, decides whether those terms +//! are ones it accepts, and decides on the same basis whether it may pass the +//! data on. No locator means no terms are declared, which a recipient must not +//! read as permission. +//! +//! Nothing here interprets a document. Core carries the locators a +//! [`PermissionSignalProvider`](crate::permission_signal::PermissionSignalProvider) +//! declares for the request and makes them visible to what consumes the data, +//! and the provider for a terms scheme decides which document applies. Model +//! Terms for Marketing (MTM) is the first such scheme to arrive and one of many +//! rather than the only one, because a publisher, a trade body or a regulator +//! can each publish terms and each set becomes a provider. +//! +//! # The document must not change +//! +//! A locator has to point at a document that is never edited once published, +//! which is why a version belongs in its address. A document that can be +//! rewritten tomorrow means a recipient can never prove what it agreed to, and +//! one edit silently rewrites the basis of every transaction already sent under +//! it. That is a property of how the document is published, so no code here can +//! check it, and it is the reason this type refuses nothing but an address that +//! could not be fetched at all. + +use core::str::FromStr; + +use error_stack::Report; +use url::Url; + +use crate::error::TrustedServerError; + +/// The address of a published terms document. +/// +/// Absolute, and `http` or `https`, because a recipient has to be able to +/// fetch and read the document. See the module documentation for why the +/// document itself must be immutable and versioned. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct Tdl(String); + +impl Tdl { + /// Builds a locator from an address. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the address is not an + /// absolute `http` or `https` URL with a host, because a recipient given + /// one of those has nothing it can fetch. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::tdl::Tdl; + /// + /// let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + /// .expect("should accept an absolute https address"); + /// assert_eq!(tdl.as_str(), "https://terms.example.com/marketing/2.txt"); + /// + /// assert!(Tdl::new("/marketing/2.txt").is_err()); + /// ``` + pub fn new(locator: &str) -> Result> { + let parsed = Url::parse(locator).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` is not an absolute URL: {error}" + ), + }) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` must be http or https, so a \ + recipient can read the document, and not `{}`", + parsed.scheme() + ), + })); + } + if parsed.host().is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("Terms document locator `{locator}` names no host"), + })); + } + Ok(Self(locator.to_owned())) + } + + /// The address, as it is carried to a recipient. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for Tdl { + type Err = Report; + + fn from_str(locator: &str) -> Result { + Self::new(locator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_https_address_is_accepted() { + let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + tdl.as_str(), + "https://terms.example.com/marketing/2.txt", + "should carry the address unchanged" + ); + } + + #[test] + fn an_absolute_http_address_is_accepted() { + assert!( + Tdl::new("http://terms.example.com/marketing/2.txt").is_ok(), + "should accept http, because a document served over http is still readable" + ); + } + + #[test] + fn a_relative_address_is_refused() { + let error = Tdl::new("/marketing/2.txt") + .expect_err("should refuse an address a recipient cannot resolve"); + assert!( + format!("{error:?}").contains("absolute"), + "should say the address is not absolute" + ); + } + + #[test] + fn another_scheme_is_refused() { + let error = + Tdl::new("mailto:terms@example.com").expect_err("should refuse a scheme with no page"); + assert!( + format!("{error:?}").contains("http or https"), + "should name the schemes a recipient can read" + ); + } + + #[test] + fn an_empty_address_is_refused() { + assert!( + Tdl::new("").is_err(), + "should refuse an empty address rather than carry it to a recipient" + ); + } + + #[test] + fn parsing_from_a_string_gives_the_same_answer() { + let parsed: Tdl = "https://terms.example.com/marketing/2.txt" + .parse() + .expect("should parse an absolute https address"); + assert_eq!( + parsed, + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"), + "should match the constructor" + ); + } + + #[test] + fn two_locators_for_the_same_document_compare_equal() { + let one = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + one, two, + "should compare equal so the same document is carried once" + ); + } + + #[test] + fn versions_of_one_document_are_different_locators() { + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let three = Tdl::new("https://terms.example.com/marketing/3.txt") + .expect("should accept an absolute https address"); + assert_ne!( + two, three, + "should distinguish versions, because a recipient agreed to one of them" + ); + } +} diff --git a/crates/trusted-server-js/lib/src/core/permissions.ts b/crates/trusted-server-js/lib/src/core/permissions.ts index 636e61af6..19929f2e5 100644 --- a/crates/trusted-server-js/lib/src/core/permissions.ts +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -7,6 +7,17 @@ function isSnapshot(value: unknown): value is PermissionsSnapshot { return typeof value === 'object' && value !== null; } +// Page code reads `set` and `tdls` without checking either exists, so both are +// arrays whatever arrived. The edge always sends both, and a page assigning a +// snapshot by hand, or an older edge, may not. +function normalize(snapshot: PermissionsSnapshot): PermissionsSnapshot { + return { + ...snapshot, + set: Array.isArray(snapshot.set) ? snapshot.set : [], + tdls: Array.isArray(snapshot.tdls) ? snapshot.tdls : [], + }; +} + /** * Install the `permissions` accessor and `whenPermissions()` on the API object. * @@ -20,7 +31,9 @@ export function installPermissions(api: TsjsApi): void { // A value already on the API object came from the head-open injection, so it // is the current value; otherwise page code must still read a defined value. const injected = api.permissions; - let current: PermissionsSnapshot = isSnapshot(injected) ? injected : { set: [] }; + let current: PermissionsSnapshot = isSnapshot(injected) + ? normalize(injected) + : { set: [], tdls: [] }; let settled = false; let resolvePending: (snapshot: PermissionsSnapshot) => void = () => {}; const pending = new Promise((resolve) => { @@ -38,9 +51,9 @@ export function installPermissions(api: TsjsApi): void { return current; }, set(value: PermissionsSnapshot) { - current = value; - log.debug('permissions: received', value); - settle(value); + current = isSnapshot(value) ? normalize(value) : { set: [], tdls: [] }; + log.debug('permissions: received', current); + settle(current); }, enumerable: true, configurable: true, diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 436f21617..8a9af2e77 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -370,9 +370,17 @@ export interface GptSlotHandoff { * * The names in `set` are IAB Privacy Taxonomy Data Use keys, as resolved by the * server for this request. + * + * `tdls` are the terms documents the data for this request is available under, + * as declared by the permission signal providers the deployment runs, in the + * order they were asked. Each entry addresses a published document a person can + * read. An empty list says no terms were declared, which is not the same as + * terms that permit anything, so page code that needs a basis and finds none + * has none. */ export interface PermissionsSnapshot { set: string[]; + tdls: string[]; } export interface TsjsApi { diff --git a/crates/trusted-server-js/lib/test/core/permissions.test.ts b/crates/trusted-server-js/lib/test/core/permissions.test.ts index 9a12b340a..fe0ece6e5 100644 --- a/crates/trusted-server-js/lib/test/core/permissions.test.ts +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -21,14 +21,17 @@ describe('core/permissions', () => { }); it('keeps permissions injected before the bundle loads and resolves with them', async () => { - const injected: PermissionsSnapshot = { set: ['necessary.operations'] }; + const injected: PermissionsSnapshot = { + set: ['necessary.operations'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; window.tsjs = { permissions: injected } as TsjsApi; await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - expect(api.permissions).toEqual({ set: ['necessary.operations'] }); - await expect(api.whenPermissions!()).resolves.toEqual({ set: ['necessary.operations'] }); + expect(api.permissions).toEqual(injected); + await expect(api.whenPermissions!()).resolves.toEqual(injected); }); it('resolves on the body seam assignment and reads the value back', async () => { @@ -36,10 +39,16 @@ describe('core/permissions', () => { const api = window.tsjs as TsjsApi; const settled = api.whenPermissions!(); - api.permissions = { set: ['marketing.advertising.serving'] }; + api.permissions = { set: ['marketing.advertising.serving'], tdls: [] }; - await expect(settled).resolves.toEqual({ set: ['marketing.advertising.serving'] }); - expect(api.permissions).toEqual({ set: ['marketing.advertising.serving'] }); + await expect(settled).resolves.toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); + expect(api.permissions).toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); }); it('falls back to the empty default when no assignment arrives before DOMContentLoaded', async () => { @@ -49,7 +58,7 @@ describe('core/permissions', () => { const settled = api.whenPermissions!(); document.dispatchEvent(new Event('DOMContentLoaded')); - await expect(settled).resolves.toEqual({ set: [] }); + await expect(settled).resolves.toEqual({ set: [], tdls: [] }); }); it('resolves at once when the document has already been parsed', async () => { @@ -59,18 +68,45 @@ describe('core/permissions', () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - await expect(api.whenPermissions!()).resolves.toEqual({ set: [] }); + await expect(api.whenPermissions!()).resolves.toEqual({ set: [], tdls: [] }); }); it('returns the same resolved value from every later call', async () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - api.permissions = { set: ['analytics.reporting'] }; + api.permissions = { set: ['analytics.reporting'], tdls: [] }; const first = await api.whenPermissions!(); const second = await api.whenPermissions!(); expect(second).toBe(first); - expect(second).toEqual({ set: ['analytics.reporting'] }); + expect(second).toEqual({ set: ['analytics.reporting'], tdls: [] }); + }); + + it('gives page code both lists when the edge sent only the permissions', async () => { + // An older edge, or a page assigning a snapshot by hand, sends no terms. + // Page code still reads `tdls` without checking it exists. + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { set: ['analytics.reporting'] } as PermissionsSnapshot; + + expect(api.permissions.tdls).toEqual([]); + await expect(api.whenPermissions!()).resolves.toEqual({ + set: ['analytics.reporting'], + tdls: [], + }); + }); + + it('carries the terms the edge declared for the request', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { + set: ['necessary.operations.storage'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; + + expect(api.permissions.tdls).toEqual(['https://terms.example.com/marketing/2.txt']); }); }); diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 9355ad62c..5a34f7aeb 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -436,7 +436,9 @@ denied or who was classified as a bot still receives the state, empty in that case rather than missing. Because the arrival point moves, a page module must not read `tsjs.permissions` -directly at load. TSJS core defaults the value to `{ set: [] }` and exposes +directly at load. TSJS core defaults the value to `{ set: [], tdls: [] }`, where +`tdls` are the terms documents the request's data is available under as +declared by the signal providers, and exposes `tsjs.whenPermissions()`, a promise that resolves when the real value arrives, immediately in the head-first case or at the body seam, with a `DOMContentLoaded` fallback. That promise is the waiting point for a vendor page diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md index 36a8f6af1..bbfc7898c 100644 --- a/docs/guide/permission-signals.md +++ b/docs/guide/permission-signals.md @@ -40,7 +40,9 @@ reads its own signal from the request, without a change to the core. The next on is Model Terms for Marketing (MTM), where a publisher and the parties it passes data to agree to be bound by a published set of terms, and what a provider reads is whether that agreement covers this request. MTM arrives in a following -pull request, so the four here are a starting set and not the list. +pull request, and it is one of many terms schemes rather than the only one, +because a publisher, a trade body or a regulator can each publish terms and +each set becomes a provider. The four here are a starting set and not the list. ## How a request resolves @@ -117,6 +119,35 @@ away, and whether a TCF record answers for the deployment at all, remain the policy's decisions in the `signals` section of `permissions.yaml`, so a deployment changes those without changing a provider. +## The terms the data is available under + +A provider may also declare the terms documents the request's data is available +under, and the permission state carries what every configured provider declared, +in the order they were asked. The page reads them as `tdls` alongside `set`: + +```json +{ + "set": ["necessary.operations.storage"], + "tdls": ["https://terms.example.com/marketing/2.txt"] +} +``` + +Whoever receives the data reads them to decide whether those are terms they +accept, and whether they may pass the data on. An empty list says no terms were +declared, which is not the same as terms permitting anything, so a recipient +needing a basis and finding none has none. + +Each entry is the address of a published document a person can read, and the +document must never be edited once published, which is why a version belongs in +its address. A document that can be rewritten tomorrow means a recipient can +never prove what it agreed to, and one edit silently rewrites the basis of every +transaction already sent under it. + +None of the four schemes that ship carries terms, so the list is empty until a +terms scheme runs. Model Terms for Marketing (MTM) is the first of many rather +than the only one, since a publisher, a trade body or a regulator can each +publish terms and each set becomes a provider. + ## Withdrawal is a separate question A provider may also say that the request explicitly withdraws a permission, From c43ebef22c80c7c301ed5fe9944701991db22729 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 10:17:56 +0100 Subject: [PATCH 087/133] Keep the provider rationale on the docs page The permission signal module documentation opened with the argument for keeping signaling schemes out of core. That is a position rather than a description of the code, and the same argument already stands in docs/guide/permission-signals.md, so the module documentation now points there and keeps to what core holds. --- .../src/permission_signal/README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 1bfad5f5b..42e2d01ef 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -10,15 +10,12 @@ answer anyone gave to a question, and a jurisdiction rule is neither. Consent is one kind of signal, so the seam takes the wider name and the consent subsystem keeps the narrower one. -## Why the providers are not in core +## What core holds -Privacy is a non-price factor of competition. Publishers, browsers and -standards bodies compete on it, and schemes come and go. Compiling a closed -list of schemes into core would settle that competition in code, because -the schemes built in would be the only ones a deployment could act on, and -the core maintainers would be deciding which privacy schemes exist. +The reasons the schemes live outside core are on the docs page, +docs/guide/permission-signals.md. -So core holds the trait, the ordering, the country baseline, and the policy +Core holds the trait, the ordering, the country baseline, and the policy vocabulary for what a deployment decides about the shipped schemes, being whether a TCF record answers, which signals count as a US-style opt-out and what an opt-out takes away. It holds no scheme's wire format and no scheme's From 2eb8712d0a3a879cc52bbb9d9039a5119f468de5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 10:17:57 +0100 Subject: [PATCH 088/133] Say what each permission signal assertion checks AGENTS.md asks for a descriptive message on every assertion, and the tests added with the permission signal seam left 21 without one. Each now states the rule it holds, so a failure names what broke rather than printing two values that differ. --- crates/permission-signal/gpc/src/lib.rs | 6 +- crates/permission-signal/gpp/src/lib.rs | 6 +- crates/permission-signal/tcf/src/lib.rs | 6 +- .../permission-signal/us-privacy/src/lib.rs | 6 +- crates/trusted-server-core/src/evidence.rs | 6 +- .../src/permission_signal/mod.rs | 76 +++++++++++++++---- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index f744dba5c..1f05fdc25 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -83,7 +83,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(GpcProvider::new().id(), ID); + assert_eq!( + GpcProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index af0b504d8..e5b402e66 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -95,7 +95,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(GppSaleOptOutProvider::new().id(), ID); + assert_eq!( + GppSaleOptOutProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 21e362942..2229c9980 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -156,7 +156,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(TcfProvider::new().id(), ID); + assert_eq!( + TcfProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index af4cc6c11..9ef4316cb 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -97,7 +97,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(UsPrivacyProvider::new().id(), ID); + assert_eq!( + UsPrivacyProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index c1aa144a7..b296f3fef 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -297,7 +297,11 @@ mod cookie_tests { Some("1"), "spaces around the name and value are not part of either" ); - assert_eq!(info.cookie("b"), Some("2")); + assert_eq!( + info.cookie("b"), + Some("2"), + "and so are the spaces around a later pair" + ); } #[test] diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 0decf789a..f30871fa1 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -609,7 +609,11 @@ mod tests { #[test] fn no_providers_leaves_the_place_baseline_alone() { - assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); + assert_eq!( + combined_signals(&[]), + ConsentSignal::Neutral, + "with no provider configured nothing amends the place baseline" + ); } #[test] @@ -634,11 +638,13 @@ mod tests { // uses undo the one that was working. assert_eq!( combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), - ConsentSignal::Grant + ConsentSignal::Grant, + "a later provider with no opinion leaves an earlier grant standing" ); assert_eq!( combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Neutral]), - ConsentSignal::Revoke + ConsentSignal::Revoke, + "and a later silence leaves an earlier refusal standing too" ); } @@ -649,7 +655,8 @@ mod tests { // not carry that scheme, which is most of them. assert_eq!( combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), - ConsentSignal::Neutral + ConsentSignal::Neutral, + "no provider having an opinion is not a refusal" ); } @@ -681,7 +688,11 @@ mod tests { let providers: Vec> = vec![fixed("opt-out", ConsentSignal::Revoke), Arc::new(Recording)]; - assert_eq!(combined(&providers), ConsentSignal::Revoke); + assert_eq!( + combined(&providers), + ConsentSignal::Revoke, + "the recording provider has no opinion, so the opt-out stands" + ); } #[test] @@ -695,7 +706,11 @@ mod tests { peer: "gpc", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Grant); + assert_eq!( + combined(&providers), + ConsentSignal::Grant, + "a later provider overrides a refusal made by the peer it consulted" + ); // The same provider leaves the refusal alone when it came from a peer // it was not told to override. @@ -706,7 +721,11 @@ mod tests { peer: "gpc", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Revoke); + assert_eq!( + combined(&providers), + ConsentSignal::Revoke, + "and leaves a refusal from any other peer standing" + ); } #[test] @@ -741,14 +760,21 @@ mod tests { input.ask("not-configured", permission).is_none(), "a provider must be able to tell a missing peer from a silent one" ); - assert!(!input.has("not-configured")); + assert!( + !input.has("not-configured"), + "and sees that the peer is not in the list" + ); assert!(input.has("absent"), "and can see itself in the list"); ConsentSignal::Neutral } } let providers: Vec> = vec![Arc::new(Absent)]; - assert_eq!(combined(&providers), ConsentSignal::Neutral); + assert_eq!( + combined(&providers), + ConsentSignal::Neutral, + "a provider that finds its peer missing leaves the permission unsettled" + ); } #[test] @@ -762,13 +788,20 @@ mod tests { fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // Without the guard this recurses until the stack is gone. - assert!(input.ask("self-asking", permission).is_none()); + assert!( + input.ask("self-asking", permission).is_none(), + "a provider asking itself gets no answer" + ); ConsentSignal::Grant } } let providers: Vec> = vec![Arc::new(SelfAsking)]; - assert_eq!(combined(&providers), ConsentSignal::Grant); + assert_eq!( + combined(&providers), + ConsentSignal::Grant, + "a provider refused its own consultation still answers for itself" + ); } #[test] @@ -786,7 +819,11 @@ mod tests { peer: "first", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Neutral); + assert_eq!( + combined(&providers), + ConsentSignal::Neutral, + "two providers consulting each other settle instead of looping" + ); } // ------------------------------------------------------------------ @@ -817,12 +854,18 @@ mod tests { // and must never destroy an identifier. let providers: Vec> = vec![fixed("opt-out", ConsentSignal::Revoke)]; - assert!(!withdrawn_under(&providers, Acquisition::RequiresSignal)); + assert!( + !withdrawn_under(&providers, Acquisition::RequiresSignal), + "a provider that only revokes never withdraws" + ); } #[test] fn no_providers_never_withdraw() { - assert!(!withdrawn_under(&[], Acquisition::RequiresSignal)); + assert!( + !withdrawn_under(&[], Acquisition::RequiresSignal), + "with no provider configured nothing can withdraw" + ); } // ------------------------------------------------------------------ @@ -863,7 +906,7 @@ mod tests { #[test] fn an_empty_list_is_acting_on_no_signal_and_is_accepted() { let selected = select(&four(), Some(&[])).expect("should accept an empty list"); - assert!(selected.is_empty()); + assert!(selected.is_empty(), "an empty list selects no provider"); assert_eq!( omitted(&four(), Some(&[])).len(), 4, @@ -901,7 +944,8 @@ mod tests { let configured = names(&["gpc", "tcf"]); assert_eq!( omitted(&four(), Some(&configured)), - vec!["gpp-sale-opt-out", "us-privacy"] + vec!["gpp-sale-opt-out", "us-privacy"], + "the providers the list leaves out are reported in the offered order" ); assert!( omitted(&four(), None).is_empty(), From cb28ad317fa8051bbd0c879d0d65ba908db451e6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 11:30:44 +0100 Subject: [PATCH 089/133] Build the Axum test state from the compiled auction plan Main's #1016 builds the orchestrator and the integration registry from a compiled auction plan, and limits IntegrationRegistry::new to core's own tests. The merge of main moved the Axum adapter's state building onto the plan and took main's imports, but missed the test helper state_with_uninjected_provider, which still called build_orchestrator and IntegrationRegistry::new, so the adapter's library tests stopped compiling. The helper now builds both from the plan, the same way build_state_with_settings does. --- crates/trusted-server-adapter-axum/src/app.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9b71f7c82..01c9c4949 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -710,8 +710,11 @@ mod tests { fn state_with_uninjected_provider() -> AppState { let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) .expect("should parse settings selecting an uninjected provider"); - let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); - let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) + .expect("should build orchestrator"); + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), From a75571d5cb255c8282bff62c98862ad44a01d6ed Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:16:58 +0100 Subject: [PATCH 090/133] Resolve the host-signals passphrase from the secret store Main's #1036 resolves secret settings from the secret store by the paths TrustedServerAppConfig::secret_fields lists, and push validation skips the validators on those paths because they hold key names. Merging main registered the [ec.providers.hmac] passphrase, but not this branch's [ec.providers.host-signals] passphrase. A key name there failed the passphrase length check when the configuration was pushed, and a key name long enough to pass would have been used unresolved as the HMAC key. The path is now listed as an optional secret and checked as a key name. EdgeZero matches secret paths against validation error keys verbatim, and the derived validation keyed the block's errors by the Rust field name host_signals, so EcProviders now validates each built-in block under the key the configuration uses. New tests cover pushing a host-signals key name, resolving one from the mapped store, and still rejecting a short resolved value. --- crates/trusted-server-core/src/config.rs | 38 +++++++++++++ .../trusted-server-core/src/config_payload.rs | 54 +++++++++++++++++++ crates/trusted-server-core/src/settings.rs | 33 ++++++++++-- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index b40a9b785..3057080c9 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -154,6 +154,15 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { ], true, ), + field( + vec![ + object("ec"), + optional_object("providers"), + optional_object("host-signals"), + object("passphrase"), + ], + true, + ), field( vec![ object("ec"), @@ -405,6 +414,12 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report "resolved-partner-api-token-32-bytes-ok", "partner-pull-token-key" => "resolved-partner-pull-token-32-bytes-ok", "trusted-client-ip-key" => "resolved-trusted-client-ip-secret-32-bytes", + "host-signals-passphrase-key" => "resolved-host-signals-passphrase-32-bytes-ok", _ => key, }; Ok(value.as_bytes().to_vec()) @@ -835,6 +836,59 @@ mod tests { ); } + #[test] + fn resolves_the_host_signals_passphrase_from_the_mapped_store() { + let mut original = test_settings(); + original.ec.provider = Some(crate::ec::provider::EcProviderSelection::from( + "host-signals", + )); + original.ec.providers.hmac = None; + original.ec.providers.host_signals = Some(crate::settings::HostSignalsProviderConfig { + passphrase: Redacted::new("host-signals-passphrase-key".to_string()), + }); + + let reconstructed = settings_from_config_blob( + &envelope_json(&original), + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect("should resolve the host-signals passphrase from the mapped store"); + + assert_eq!( + reconstructed + .ec + .providers + .host_signals + .as_ref() + .map(|config| config.passphrase.expose().as_str()), + Some("resolved-host-signals-passphrase-32-bytes-ok") + ); + } + + #[test] + fn runtime_validation_rejects_a_short_resolved_host_signals_passphrase() { + let mut settings = test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from( + "host-signals", + )); + settings.ec.providers.hmac = None; + settings.ec.providers.host_signals = Some(crate::settings::HostSignalsProviderConfig { + passphrase: Redacted::new("short_key".to_owned()), + }); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a short resolved host-signals passphrase"); + + assert!( + err.to_string().contains("short_passphrase"), + "error should name the passphrase check: {err:?}" + ); + assert!( + !err.to_string().contains("short_key"), + "error should not expose the secret value" + ); + } + #[test] fn placeholder_rejection_happens_after_secret_resolution() { let mut settings = test_settings(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index bc9dc143a..2ff2d1dd0 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -13,7 +13,7 @@ use std::sync::OnceLock; use std::time::Duration; use subtle::ConstantTimeEq as _; use url::Url; -use validator::{Validate, ValidationError}; +use validator::{Validate, ValidationError, ValidationErrors}; use crate::auction_config_types::AuctionConfig; use crate::cache_policy::{CachePolicy, CacheVisibility}; @@ -811,18 +811,16 @@ impl Ec { /// The active provider is chosen by the [`Ec::provider`] selector, and the one /// block present must be the one it names (see /// [`Ec::validate_provider_selection`]). -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct EcProviders { /// The built-in HMAC-over-client-IP provider, keyed `hmac`. #[serde(default)] - #[validate(nested)] pub hmac: Option, /// The built-in host-signal provider, keyed `host-signals`. Creates the Edge /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it /// requires a host that supplies those signals. #[serde(default, rename = "host-signals")] - #[validate(nested)] pub host_signals: Option, /// Configuration blocks for vendor or host providers that live in their own @@ -835,6 +833,33 @@ pub struct EcProviders { vendor: HashMap, } +/// Validates each built-in provider block under the key the configuration +/// uses for it. +/// +/// The derived implementation would key a nested error by the Rust field name, +/// `host_signals`, while the configuration, the secret-store resolution and the +/// secret paths `TrustedServerAppConfig::secret_fields` registers all use +/// `host-signals`. `edgezero_core::app_config::validate_excluding_secrets` +/// matches those paths against the error keys verbatim, so a derived key would +/// leave the passphrase checked as a value when it holds a key name at push +/// time. Vendor blocks are validated by the adapter that builds the provider. +impl Validate for EcProviders { + fn validate(&self) -> Result<(), ValidationErrors> { + let mut errors = ValidationErrors::new(); + if let Some(hmac) = &self.hmac { + errors.merge_self("hmac", hmac.validate()); + } + if let Some(host_signals) = &self.host_signals { + errors.merge_self("host-signals", host_signals.validate()); + } + if errors.errors().is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + impl EcProviders { /// Returns the raw configuration block for a vendor provider `key`, or /// `None` when no `[ec.providers.]` block is present. The adapter that From 3c9f4b0c3e87f142cbbbabff8e1d573e75f47130 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:35:07 +0100 Subject: [PATCH 091/133] Correct the Windows notes on CI runners and the CLI The Windows notes said CI runs the adapter tests on both ubuntu-latest and windows-latest. No job in this branch's test workflow runs on Windows, so the sentence now says ubuntu-latest. The notes also say that trusted-server-cli does not build on a Windows host, because its dependency edgezero-adapter-fastly uses a standard library feature that is unstable on Windows, so its tests and the template cache harness, which builds it, run in WSL too. --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a72063c03..7972540e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ cargo install viceroy --version 0.17.0 --locked --force The Rust adapter tests run natively on Windows through the cargo aliases (`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), -and CI runs these on both `ubuntu-latest` and `windows-latest`. +and CI runs them on `ubuntu-latest`. The Docker-based integration suite (`scripts/integration-tests.sh`) and the Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, @@ -165,6 +165,10 @@ inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the WSL distro with the same toolchain as `.tool-versions` (rustup + the `wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then run the scripts from a clone on the WSL native filesystem for fast builds. +The CLI crate (`trusted-server-cli`) does not build on a Windows host either, +because its dependency `edgezero-adapter-fastly` uses a standard library +feature that is unstable on Windows, so run its tests and the template cache +harness (`scripts/template-cache-local-test.sh`), which builds it, in WSL too. --- From 0980b73c71de9e392efca54ef11386cd12b82cfb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 11:50:30 +0100 Subject: [PATCH 092/133] Describe rejected provider effects accurately Christian Pavilonis's review thread on provider response effects asked core to validate them against the managed ts- cookies, the x-ts- namespace and framing headers. The check is in place, but several comments said a rejected effect "fails the request", which is not what happens. EcContext::generate_if_needed returns the error, and its only two callers outside tests, the publisher fallback in the Fastly adapter and IntegrationRegistry::handle_proxy, log it and serve the response without an Edge Cookie. The reserved_response_effect and apply_provider_response_headers docs, the comment in EcContext::candidate_id and the reserved-surface test now say that. The candidate_id comment no longer implies the rejection matches the identifier-bounds check in every respect, because that check runs after the provider's headers are captured. A new test, a_rejected_provider_effect_never_reaches_the_finalized_response, proves a rejected header never reaches the response. For each reserved effect it lets generation fail, runs EC finalization on the same context, and checks the response carries no forged ts-ec cookie, no x-ts-ec header and no transfer-encoding. It failed when the header capture in candidate_id was moved ahead of the reserved check, and passes with the code as it is. The test helper now hands back the context even when generation fails, so the test can finalize on it. The review thread is https://github.com/IABTechLab/trusted-server/pull/1043#discussion_r3882373907 --- crates/trusted-server-core/src/ec/mod.rs | 115 +++++++++++++++--- crates/trusted-server-core/src/ec/provider.rs | 20 +-- 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 2aab4a146..6b25e7e36 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -477,12 +477,15 @@ impl EcContext { // Check every response header the provider asked for against core's // reserved surface before any of them are kept. A provider may set its // own cookies and headers, but not a managed `ts-` cookie, a header in - // the `x-ts-` namespace, or a framing or hop-by-hop header. Rejection - // fails the request, matching the identifier-bounds rejection below: - // without it a provider could write `ts-ec` itself and bypass the - // identifier validation and identity-graph row generation enforces. - // Checked before the identifier is read, because a provider can return - // headers with no identifier at all. + // the `x-ts-` namespace, or a framing or hop-by-hop header. Without the + // check a provider could write `ts-ec` itself and bypass the identifier + // validation and identity-graph row generation enforces. A rejection + // returns an error, as the identifier-bounds rejection below does. + // Because the check runs before the headers are captured, nothing from + // a rejected provider response is kept, and the caller serves the + // response without a new Edge Cookie. Checked before the identifier is + // read, because a provider can return headers with no identifier at + // all. for (name, value) in &generated.response_headers { if let Some(effect) = provider::reserved_response_effect(name, value) { return Err(Report::new(TrustedServerError::EdgeCookie { @@ -1667,10 +1670,13 @@ mod tests { } } + /// Runs generation through `provider` and hands back the context whether or + /// not generation succeeded, so a test can finalize a response on a context + /// whose generation returned an error. fn generate_with_header_setting_provider( provider: HeaderSettingProvider, graph: Option<&KvIdentityGraph>, - ) -> (Settings, Result>) { + ) -> (Settings, EcContext, Result<(), Report>) { use crate::platform::test_support::noop_services_with_ec_provider; let mut settings = create_test_settings(); @@ -1680,18 +1686,22 @@ mod tests { let geo = non_regulated_geo(); let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) .expect("should read EC context"); - let outcome = ec.generate_if_needed(&settings, graph).map(|()| ec); - (settings, outcome) + let outcome = ec.generate_if_needed(&settings, graph); + (settings, ec, outcome) } #[test] fn generate_rejects_a_provider_effect_inside_the_reserved_response_surface() { // A provider that sets the managed `ts-ec` cookie would bypass core's - // identifier validation and its identity-graph row entirely, so the - // request fails rather than the effect being quietly dropped. The + // identifier validation and its identity-graph row entirely, so + // generation returns an error rather than quietly dropping the effect. + // The publisher and integration proxies log that error and serve the + // response without an Edge Cookie, and + // `a_rejected_provider_effect_never_reaches_the_finalized_response` + // checks that the rejected header stays off that response. The // provider creates no identifier here, which is exactly the case the // cookie write would otherwise slip through. - let (_settings, outcome) = generate_with_header_setting_provider( + let (_settings, _ec, outcome) = generate_with_header_setting_provider( HeaderSettingProvider { name: "set-cookie", value: "ts-ec=forged-value; Path=/", @@ -1700,7 +1710,8 @@ mod tests { None, ); - let err = outcome.expect_err("a managed cookie effect should fail the request"); + let err = + outcome.expect_err("a managed cookie effect should make generation return an error"); assert!( err.to_string().contains("header-setting"), "the error should name the provider, got: {err}" @@ -1708,7 +1719,7 @@ mod tests { // The same for the reserved header namespace and for message framing. for (name, value) in [("x-ts-ec", "forged"), ("transfer-encoding", "chunked")] { - let (_settings, outcome) = generate_with_header_setting_provider( + let (_settings, _ec, outcome) = generate_with_header_setting_provider( HeaderSettingProvider { name, value, @@ -1718,7 +1729,77 @@ mod tests { ); assert!( outcome.is_err(), - "`{name}` is reserved and should fail the request" + "`{name}` is reserved, so generation should return an error" + ); + } + } + + #[test] + fn a_rejected_provider_effect_never_reaches_the_finalized_response() { + // Returning the error is half of the rule. The caller logs the error + // and still serves the response, and EC finalization runs on that + // response with this same context, so a rejected header kept anywhere + // on the context would reach the browser anyway. The provider also + // returns an identifier here, to show that nothing from the rejected + // provider response is kept. + for (name, value) in [ + ("set-cookie", "ts-ec=forged-value; Path=/"), + ("x-ts-ec", "forged"), + ("transfer-encoding", "chunked"), + ] { + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, mut ec, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name, + value, + mint: true, + }, + Some(&graph), + ); + assert!( + outcome.is_err(), + "`{name}` is reserved, so generation should return an error" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after `{name}` is rejected" + ); + + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|cookie| cookie.to_str().ok()) + .collect(); + assert!( + !cookies.iter().any(|cookie| cookie.contains("ts-ec=forged")), + "the rejected `{name}` effect should not set `ts-ec`, got: {cookies:?}" + ); + assert!( + response.headers().get("x-ts-ec").is_none(), + "the rejected `{name}` effect should not set `x-ts-ec`" + ); + assert!( + response + .headers() + .get(http::header::TRANSFER_ENCODING) + .is_none(), + "the rejected `{name}` effect should not set `transfer-encoding`" ); } } @@ -1729,7 +1810,7 @@ mod tests { // it survives generation and reaches the browser response unchanged, // alongside the managed `ts-ec` cookie core writes itself. let graph = KvIdentityGraph::in_memory("test-ec-store"); - let (settings, outcome) = generate_with_header_setting_provider( + let (settings, mut ec, outcome) = generate_with_header_setting_provider( HeaderSettingProvider { name: "set-cookie", value: "acme-evidence=abc123; Path=/; Secure", @@ -1737,7 +1818,7 @@ mod tests { }, Some(&graph), ); - let mut ec = outcome.expect("a provider-owned cookie should not fail the request"); + outcome.expect("generation should accept a provider-owned cookie"); assert_eq!( ec.ec_value(), Some("t0hs~provider-value"), diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 4cc209f94..688942128 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -252,10 +252,15 @@ fn set_cookie_name(value: &[u8]) -> &[u8] { /// later request, for example), so the rule reserves core's namespace rather /// than banning `Set-Cookie` outright. /// -/// A rejected effect fails the request rather than being dropped, because a +/// A rejected effect is not simply dropped while the rest of the provider +/// response goes ahead. Generation returns an error instead, as it does for a +/// provider creating an identifier outside the cookie-safe alphabet, because a /// provider reaching into the reserved surface has broken its contract in the -/// same way as one creating an identifier outside the cookie-safe alphabet, and -/// that already fails the request. Serving the response instead would let a +/// same way. The check runs before anything from that provider response is +/// kept, so neither its identifier nor any of its headers is kept. The +/// publisher proxy and integration proxy log the error and serve the response +/// without an Edge Cookie, and orphan recovery in EC finalization leaves the +/// visitor's existing cookie in place. Applying the header instead would let a /// provider set `ts-ec` directly, bypassing core's identifier validation and /// its requirement that a created identifier have an identity-graph row. #[must_use] @@ -302,10 +307,11 @@ pub fn reserved_response_effect( /// `Vary: Accept-Encoding` with the provider's own would break the cache /// correctness the origin asked for. /// - The single-valued headers where replacing would be the right answer are -/// exactly the ones a provider must not author at all, and -/// [`reserved_response_effect`] already fails the request for them: core's -/// `x-ts-` namespace, the `ts-` managed cookies, and the framing and -/// hop-by-hop set. +/// exactly the ones a provider must not author at all, being core's `x-ts-` +/// namespace, the `ts-` managed cookies, and the framing and hop-by-hop set. +/// [`reserved_response_effect`] rejects those before anything from the +/// provider response is kept, so generation returns an error and none of +/// them reaches the response. /// /// So nothing a provider is permitted to set here needs to replace, and /// accumulating is the direction that cannot silently destroy someone else's From 0e7f7eb54a9e835645697e73b58669c33e0bc113 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:13:32 +0100 Subject: [PATCH 093/133] Key the remaining identity-graph paths canonically Christian Pavilonis's review thread on partner paths asked for validation and KV normalization to be dispatched by provider code. Validation already was, but several paths still read or wrote identity graph rows under the identifier as issued, while generation stores each row under the owning provider's canonical form. For a provider whose canonical form differs from the cookie value those paths found no row. - Pull sync validated the identifier but kept only the raw value. It looked the request snapshot up under that value, while generation binds the snapshot to the canonical key and every read EC finalization makes uses that key, so it skipped every partner. Its revalidation read and write-back used the raw value too. - The admin lookup answered 404 for a row that exists. - The /auction, publisher navigation and /_ts/page-bids preloads loaded a miss, and resolve_auction_eids matched the snapshot by the raw identifier, so auctions carried no server-side EIDs. - The navigation preload also replaced the snapshot generation had just bound to the canonical key with that miss, so a newly created identifier got no ts-ec cookie on a navigation with no EID cookies to ingest. Pull sync now carries the canonical key and uses it for the request snapshot lookup, the revalidation read and the write-back. Partners still receive the identifier as issued, and the pull rate limit key still hashes the issued identifier, which leaves rate limiting unchanged for every provider. The admin lookup reads under the key, reports the requested identifier as ec_id, and adds the key it read as kv_key, which the API reference now describes. The three preloads load under EcContext::ec_kv_key and resolve_auction_eids looks the entry up under the key. EcContext::accepts_id lost its only caller and is removed. For the built-in HMAC provider the canonical key is the identifier itself for every identifier read-back accepts, so these paths behave as before for HMAC cookies. Apart from the new kv_key field, the one change an HMAC deployment can see is that an admin lookup given an identifier with an uppercase hash now finds the row stored under the lowercase key instead of answering 404. Each path has a test using CanonicalizingProvider, whose identifier t0ca~MiXeD.CaseId is stored under t0ca~mixed.caseid, and all seven new tests failed before the fix. The shared constants for that identifier now live beside the provider, so the identify, finalization and new tests use one definition. The known-gap note on EcContext::kv_key_for, which cited commit 343ac3e from outside this branch, and the matching note on AcceptedProviders now state which paths key rows through the canonical form. The review thread is https://github.com/IABTechLab/trusted-server/pull/1043#discussion_r3882373920 --- .../src/auction/endpoints.rs | 95 +++++- crates/trusted-server-core/src/ec/admin.rs | 147 +++++++-- crates/trusted-server-core/src/ec/finalize.rs | 11 +- crates/trusted-server-core/src/ec/identify.rs | 12 +- crates/trusted-server-core/src/ec/mod.rs | 66 ++-- crates/trusted-server-core/src/ec/provider.rs | 15 +- .../trusted-server-core/src/ec/pull_sync.rs | 115 ++++++- crates/trusted-server-core/src/publisher.rs | 281 +++++++++++++++++- docs/guide/api-reference.md | 2 +- 9 files changed, 641 insertions(+), 103 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0c0a7792..38af9d29f 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -303,8 +303,13 @@ pub async fn handle_auction( // EC and both KV and partner stores are available. Gate the read on a // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. + // The row is read under the owning provider's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -451,7 +456,13 @@ pub(crate) fn resolve_auction_eids( let ec_id = ec_context.ec_value()?; - let Some(entry) = snapshot.entry_for(ec_id) else { + // Callers read the snapshot under the identity-graph key, the owning + // provider's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -624,6 +635,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingProvider}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -796,6 +808,85 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_loads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning provider's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a provider whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = + make_ec_context(Jurisdiction::NonRegulated, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(Arc::new(CanonicalizingProvider)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 06b9a30a8..b651f0685 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -197,8 +197,11 @@ pub fn deny_admin_diagnostic_fallback(req: &Request) -> Option ec_id, + let requested = match requested_ec_id(req, &AcceptedProviders::active(provider)) { + Ok(requested) => requested, Err(response) => return Ok(*response), }; - let Some(lookup) = kv.lookup_raw(&ec_id)? else { - log::info!("Admin EC lookup: no entry for '{}'", log_id(&ec_id)); + // Read the row under the owning provider's canonical form of the + // identifier, the key the row is stored under, rather than under the + // identifier as requested. The two differ for a provider whose canonical + // form is not the cookie value. + let Some(lookup) = kv.lookup_raw(&requested.kv_key)? else { + log::info!( + "Admin EC lookup: no entry for '{}'", + log_id(&requested.ec_id) + ); return Ok(json_error( StatusCode::NOT_FOUND, "EC entry not found (KV reads are eventually consistent; a very \ @@ -297,8 +307,11 @@ pub fn handle_admin_ec_lookup( )); }; - log::info!("Admin EC lookup: returning entry for '{}'", log_id(&ec_id)); - let payload = build_lookup_response(registry, kv.store_name(), ec_id, &lookup); + log::info!( + "Admin EC lookup: returning entry for '{}'", + log_id(&requested.ec_id) + ); + let payload = build_lookup_response(registry, kv.store_name(), requested, &lookup); let body = serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { message: "failed to serialize admin EC lookup response".to_owned(), @@ -341,19 +354,31 @@ fn cookie_ec_id(req: &Request) -> Result, accepted_providers: &AcceptedProviders<'_>, -) -> Result>> { +) -> Result>> { let remainder = req .uri() .path() @@ -367,16 +392,16 @@ fn requested_ec_id( remainder.to_owned() }; - if !accepted_providers.accepts(&ec_id) { + let Some(kv_key) = accepted_providers.canonical_kv_key(&ec_id) else { return Err(Box::new(json_error( StatusCode::BAD_REQUEST, "invalid EC ID: not an identifier any provider this deployment reads \ issued (the built-in HMAC provider issues hmac~{64hex}.{6alnum} and \ still reads the bare legacy form)", ))); - } + }; - Ok(ec_id) + Ok(RequestedEcId { ec_id, kv_key }) } /// Builds the success payload from a raw KV lookup. @@ -386,11 +411,12 @@ fn requested_ec_id( fn build_lookup_response( registry: &PartnerRegistry, store_name: &str, - ec_id: String, + requested: RequestedEcId, lookup: &EcKvLookup, ) -> AdminEcLookupResponse { let mut payload = AdminEcLookupResponse { - ec_id, + ec_id: requested.ec_id, + kv_key: requested.kv_key, store: store_name.to_owned(), generation: lookup.generation, tombstone: None, @@ -694,6 +720,7 @@ mod tests { use crate::ec::kv_backend::test_support::InMemoryEcKv; use crate::ec::kv_backend::{EcKvStore as _, EcKvWrite, EcKvWriteMode}; use crate::ec::kv_types::KvPartnerId; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingProvider}; use crate::redacted::Redacted; use crate::settings::EcPartner; @@ -1576,10 +1603,17 @@ mod tests { let coded = format!("hmac~{}", test_ec_id()); let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); - let ec_id = requested_ec_id(&request, &AcceptedProviders::active(None)) + let requested = requested_ec_id(&request, &AcceptedProviders::active(None)) .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); - assert_eq!(ec_id, coded, "should look up the identifier as given"); + assert_eq!( + requested.ec_id, coded, + "should report the identifier as given" + ); + assert_eq!( + requested.kv_key, coded, + "a lowercase HMAC identifier should be its own identity-graph key" + ); } #[test] @@ -1592,9 +1626,12 @@ mod tests { let opaque = "t0op~Opaque_Value_MixedCase"; let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); - let ec_id = requested_ec_id(&request, &accepted) + let requested = requested_ec_id(&request, &accepted) .unwrap_or_else(|_| panic!("should accept the active provider's identifier")); - assert_eq!(ec_id, opaque, "should look up the identifier as given"); + assert_eq!( + requested.ec_id, opaque, + "should report the identifier as given" + ); // A code no configured provider reads stays a 400, even in the built-in // HMAC shape, so one deployment cannot inspect another's identifiers. @@ -1608,4 +1645,76 @@ mod tests { "an unread provider code should be a 400" ); } + + #[test] + fn ec_lookup_reads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning provider's + // canonical form of the identifier. Read under the identifier as + // requested, the lookup answered 404 for a row that exists whenever a + // provider's canonical form differs from the cookie value. + let kv = kv_with_entry(CANONICAL_KV_KEY, &sample_entry()); + let req = + get_request_with_cookie("/_ts/admin/ec", &format!("ts-ec={CANONICAL_COOKIE_VALUE}")); + + let response = handle_admin_ec_lookup( + Some(&kv), + &test_registry(), + Some(&CanonicalizingProvider), + &req, + ) + .expect("should handle lookup"); + + assert_eq!( + response.status(), + StatusCode::OK, + "the cookie value should find the row stored under the canonical key" + ); + let json = response_json(response); + assert_eq!( + json["ec_id"], CANONICAL_COOKIE_VALUE, + "should report the identifier as requested" + ); + assert_eq!( + json["kv_key"], CANONICAL_KV_KEY, + "should report the key the entry was read from" + ); + assert_eq!( + json["entry"]["ids"]["bidstream.example"]["uid"], "uid-live", + "should return the entry stored under the canonical key" + ); + } + + #[test] + fn ec_lookup_given_an_uppercase_hmac_hash_reads_the_lowercase_row() { + // The built-in HMAC provider issues lowercase hex, and its canonical + // form lowercases the hash, so its row key is the identifier it issued. + // An operator who pastes that identifier with the hash in uppercase is + // still asking for the same row. + let ec_id = format!("hmac~{}", test_ec_id()); + let kv = kv_with_entry(&ec_id, &sample_entry()); + let uppercase = format!("hmac~{}.abc123", "A".repeat(64)); + let provider = crate::ec::tests::hmac_provider(); + let req = get_request(&format!("/_ts/admin/ec/{uppercase}")); + + let response = + handle_admin_ec_lookup(Some(&kv), &test_registry(), Some(provider.as_ref()), &req) + .expect("should handle lookup"); + + assert_eq!( + response.status(), + StatusCode::OK, + "an uppercase hash should find the row stored under the lowercase key" + ); + let json = response_json(response); + assert_eq!( + json["ec_id"], + uppercase.as_str(), + "should report the identifier as requested" + ); + assert_eq!( + json["kv_key"], + ec_id.as_str(), + "should report the lowercase key the entry was read from" + ); + } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index c1f49dbe5..8d572ed76 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -444,6 +444,7 @@ mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; @@ -513,16 +514,6 @@ mod tests { ) } - /// The identifier [`CanonicalizingProvider`] creates, as the browser carries - /// it in the `ts-ec` cookie. - const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; - - /// The identity-graph key generation writes that identifier's row under. - /// Pinned to the creation path by - /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the - /// `ec` module tests. - const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; - fn canonicalizing_context( ec_was_present: bool, ec_generated: bool, diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index c0323b774..c068cf669 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -346,6 +346,7 @@ mod tests { use super::*; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; @@ -363,17 +364,6 @@ mod tests { ); } - /// The identifier [`CanonicalizingProvider`] creates, as the browser - /// carries it in the `ts-ec` cookie. - const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; - - /// The identity-graph key generation writes that identifier's row under. - /// Pinned to the creation path by - /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the - /// `ec` module tests, which asserts both the key it writes and the key - /// [`EcContext::ec_kv_key`] derives. - const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; - fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { source: ConsentSource::Cookie, diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 6b25e7e36..199bc6616 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -638,38 +638,24 @@ impl EcContext { provider::AcceptedProviders::active(self.selected_provider.as_deref()) } - /// Returns whether `value` is a well-formed identifier for the selected - /// provider. - /// - /// Lets core validate a cookie or active identifier (for example before - /// withdrawing it) through the provider that issued it, rather than assuming - /// the built-in shape. The global cookie bounds are checked first, then the - /// provider-specific part is dispatched by the identifier's code. Falls back - /// to the built-in shape when no provider is configured. - #[must_use] - pub(crate) fn accepts_id(&self, value: &str) -> bool { - self.accepted_providers().accepts(value) - } - /// The identity-graph key for `value` under the providers this deployment /// reads. /// - /// The canonical route from an identifier to a row key. The organic - /// generate, identify and finalize paths turn an identifier into a row key - /// through this (or through [`ec_kv_key`](Self::ec_kv_key), which wraps it), - /// so a provider whose canonical form differs from the cookie value still - /// finds the row it created. The owning provider is picked by the - /// identifier's `{code}~` prefix and supplies the canonical form of its own - /// value part, matching what - /// [`generate_if_needed`](Self::generate_if_needed) wrote at creation. + /// The canonical route from a request's identifier to a row key, so a + /// provider whose canonical form differs from the cookie value still finds + /// the row it created. The owning provider is picked by the identifier's + /// `{code}~` prefix and supplies the canonical form of its own value part, + /// matching the key [`generate_if_needed`](Self::generate_if_needed) wrote + /// at creation. /// - /// Known gap: pull sync (`ec::pull_sync`) and the admin lookup - /// (`ec::admin`) still key rows by the raw active identifier rather than - /// this canonical form, so for a provider whose canonical form differs from - /// the cookie value they can read or write under the wrong key. The three - /// organic paths were routed through the canonical form (commit - /// `343ac3e`); these two were left keying raw and are tracked as a known - /// issue for a later change. + /// Identify, EC finalization and pull sync read and write this + /// identifier's row under this key, and so do the snapshot reads and EID + /// resolution of the publisher navigation, `/auction` and `/_ts/page-bids` + /// paths. Each reaches the key through this or through + /// [`ec_kv_key`](Self::ec_kv_key), which wraps it. Batch sync and the admin + /// lookup have no EC context, so they call + /// [`AcceptedProviders::canonical_kv_key`](provider::AcceptedProviders::canonical_kv_key) + /// directly, which this wraps. /// /// `None` when no provider this deployment reads owns `value`, in which /// case there is no row to read or write. @@ -984,7 +970,7 @@ pub(crate) fn current_timestamp() -> u64 { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; @@ -1860,8 +1846,9 @@ mod tests { /// A provider whose identifier normalizes to a distinct canonical form, to /// prove the identity graph is keyed by the canonical form. /// - /// Shared with the identify and finalization tests, which need a provider - /// whose canonical key is not the value the browser carries. + /// Shared with the identify, finalization, pull sync, admin lookup, auction + /// and publisher tests, which need a provider whose canonical key is not the + /// value the browser carries. #[derive(Debug)] pub(crate) struct CanonicalizingProvider; @@ -1894,6 +1881,17 @@ mod tests { } } + /// The identifier [`CanonicalizingProvider`] creates, as the browser + /// carries it in the `ts-ec` cookie. + pub(crate) const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier`, which + /// asserts both the key generation writes and the key + /// [`EcContext::ec_kv_key`] derives. + pub(crate) const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + /// The built-in HMAC provider, as an HMAC deployment selects it. /// /// Creating or rotating an identifier needs a selected provider, so the @@ -1922,12 +1920,12 @@ mod tests { assert_eq!( ec.ec_value(), - Some("t0ca~MiXeD.CaseId"), + Some(CANONICAL_COOKIE_VALUE), "the cookie value keeps the provider's exact identifier under its code" ); assert!( graph - .get("t0ca~mixed.caseid") + .get(CANONICAL_KV_KEY) .expect("should read the graph") .is_some(), "the graph row should be keyed by the code plus the canonical form" @@ -1937,7 +1935,7 @@ mod tests { // row through `ec_kv_key`, so the two must never drift apart. assert_eq!( ec.ec_kv_key().as_deref(), - Some("t0ca~mixed.caseid"), + Some(CANONICAL_KV_KEY), "the read-side key should be the key generation wrote" ); } diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 688942128..10766a316 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -513,12 +513,12 @@ pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String /// second provider's identifiers can never be adopted or written under this /// deployment's keys. /// -/// Batch sync keys its rows through [`canonical_kv_key`](Self::canonical_kv_key) -/// here. Pull sync and the admin lookup still read and write rows by the raw -/// active identifier rather than the canonical form, so for a provider whose -/// canonical form differs from the cookie value they can key the wrong row. -/// That gap is recorded on `EcContext::kv_key_for` and tracked as a known -/// issue for a later change. +/// All three read and write rows under the key +/// [`canonical_kv_key`](Self::canonical_kv_key) returns rather than under the +/// identifier as given, so a provider whose canonical form differs from the +/// cookie value still reaches the row it created. Batch sync and the admin +/// lookup call it directly. Pull sync reaches it through `EcContext::kv_key_for` +/// and still sends partners the identifier as issued. /// /// The set holds the deployment's active provider. The design's /// `legacy_providers` reader list, the providers that never create but must still @@ -581,8 +581,7 @@ impl<'a> AcceptedProviders<'a> { provider_owns_id(owner, &key).then_some(key) } // No provider is selected, so there is no code to dispatch on and - // the built-in HMAC grammar is the fallback, the same fallback - // `EcContext::accepts_id` has always used for a stateless + // the built-in HMAC grammar is the fallback for a stateless // deployment. None if self.readers.is_empty() => { let key = generation::normalize_ec_id_for_kv(full); diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index e291695bc..9220b983e 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -33,7 +33,11 @@ use super::current_timestamp; /// Inputs needed to dispatch pull sync after response flush. #[derive(Debug, Clone)] pub struct PullSyncContext { + /// The EC ID as issued, which partners receive. ec_id: String, + /// The identity-graph key for `ec_id`, the owning provider's canonical form + /// of it, which every identity-graph read and write in pull sync uses. + kv_key: String, snapshot: EcKvSnapshot, } @@ -57,7 +61,8 @@ struct PullSyncResponse { /// Builds post-send pull-sync context from the route EC context. /// -/// Returns `None` when consent denies EC or there is no active EC ID. +/// Returns `None` when consent denies EC, there is no active EC ID, or no +/// provider this deployment reads owns the active EC ID. #[must_use] pub fn build_pull_sync_context(ec_context: &EcContext) -> Option { if !ec_context.ec_allowed() { @@ -67,25 +72,36 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(crate::ec::EcKvSnapshot::NotRead, |kv_key| { + graph.load_snapshot(&kv_key) + }), _ => crate::ec::EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response @@ -21330,6 +21343,11 @@ mod tests { /// tests drive the real handlers with a divergent edge host and assert on /// the auction request the orchestrator dispatched and on the telemetry rows /// the handler emitted. + /// + /// The same capturing setup also covers how both paths key the identity + /// graph. A provider whose canonical form differs from the cookie value has + /// its row read under the canonical key, so the partner ID stored there + /// reaches the dispatched auction request. mod navigation_publisher_domain_tests { use super::*; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -21337,6 +21355,7 @@ mod tests { use crate::auction::types::AuctionRequest; use crate::auction::{AuctionContext, AuctionOrchestrator}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingProvider}; use crate::platform::test_support::{ NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, }; @@ -21739,5 +21758,255 @@ mod tests { assert_only_renderable_slot_was_auctioned(&captured); } + + /// The bidstream partner whose stored ID the canonical row carries. + const CANONICAL_ROW_PARTNER: &str = "ssp.example.com"; + + /// The partner ID the canonical row stores for [`CANONICAL_ROW_PARTNER`]. + const CANONICAL_ROW_UID: &str = "partner-uid-123"; + + /// An identity graph holding one live row under [`CANONICAL_KV_KEY`], + /// and a registry that forwards that row's partner ID as an EID. + fn canonical_row_graph_and_registry() -> (KvIdentityGraph, PartnerRegistry) { + let graph = KvIdentityGraph::in_memory("navigation-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + CANONICAL_ROW_PARTNER, + CANONICAL_ROW_UID, + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let registry = PartnerRegistry::from_config(&[crate::settings::EcPartner { + name: "Canonical row partner".to_owned(), + source_domain: CANONICAL_ROW_PARTNER.to_owned(), + openrtb_atype: crate::settings::EcPartner::default_openrtb_atype(), + bidstream_enabled: true, + api_token: Some(crate::redacted::Redacted::new( + "canonical-row-partner-token-32-bytes".to_owned(), + )), + batch_rate_limit: crate::settings::EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: crate::settings::EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: crate::settings::EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + }]) + .expect("should build a registry with one bidstream partner"); + (graph, registry) + } + + /// A returning visitor carrying the identifier [`CanonicalizingProvider`] + /// creates, with consent that permits the server-side auction. + fn canonicalizing_returning_visitor() -> EcContext { + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + EcContext::new_for_test(Some(CANONICAL_COOKIE_VALUE.to_owned()), consent) + .with_provider_for_test(Arc::new(CanonicalizingProvider)) + } + + /// Asserts the dispatched auction request carried the partner ID the + /// canonical row stores, and the request snapshot is bound to the + /// canonical key. + fn assert_auction_used_the_canonical_row( + captured: &Arc>>, + ec_context: &EcContext, + ) { + let request = captured + .lock() + .expect("should lock captured request") + .clone() + .expect("should dispatch an auction request"); + let eids = request + .user + .eids + .expect("the auction should carry server-side EIDs from the identity graph"); + assert!( + eids.iter().any(|eid| eid.source == CANONICAL_ROW_PARTNER + && eid.uids.iter().any(|uid| uid.id == CANONICAL_ROW_UID)), + "the auction should carry the partner ID stored under the canonical key, got {eids:?}" + ); + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the request snapshot should be bound to the canonical key" + ); + } + + #[tokio::test] + async fn initial_navigation_reads_the_identity_row_under_the_canonical_key() { + // The identity graph stores a row under the owning provider's + // canonical form of the identifier. Preloaded and resolved under + // the identifier as issued, a provider whose canonical form differs + // from the cookie value found no row, so the auction carried no + // server-side EIDs. + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let (graph, registry) = canonical_row_graph_and_registry(); + let mut ec_context = canonicalizing_returning_visitor(); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let _ = handle_publisher_request( + &settings, + &services, + Some(&graph), + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: Some(®istry), + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + + assert_auction_used_the_canonical_row(&captured, &ec_context); + } + + #[tokio::test] + async fn initial_navigation_keeps_a_new_identifiers_cookie() { + // Generation binds the request snapshot to the canonical key. The + // navigation preload that follows read under the identifier as + // issued, found nothing there, and replaced that snapshot with a + // miss, so EC finalization skipped the cookie for the identifier + // this request had just created. + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let graph = KvIdentityGraph::in_memory("navigation-new-identifier-store"); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent) + .with_provider_for_test(Arc::new(CanonicalizingProvider)); + ec_context + .generate_if_needed(&settings, Some(&graph)) + .expect("should create the identifier through the provider"); + assert_eq!( + ec_context.ec_value(), + Some(CANONICAL_COOKIE_VALUE), + "test precondition: the provider should create its identifier" + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let _ = handle_publisher_request( + &settings, + &services, + Some(&graph), + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + + let mut response = Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && !cookie.contains("Max-Age=0")), + "the identifier this request created should reach the browser, got {cookies:?}" + ); + } + + #[tokio::test] + async fn page_bids_reads_the_identity_row_under_the_canonical_key() { + // The same keying for the SPA re-auction endpoint, which loads the + // row itself only once a live auction will run. + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let services = services_with( + Arc::new(crate::platform::test_support::NoopHttpClient), + Arc::new(RecordingTelemetrySink::default()), + ); + let (graph, registry) = canonical_row_graph_and_registry(); + let mut ec_context = canonicalizing_returning_visitor(); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri(format!( + "https://{EDGE_HOST}/_ts/page-bids?path=/2024/01/my-article/" + )) + .header(header::HOST, EDGE_HOST) + .body(EdgeBody::empty()) + .expect("should build test request"); + req.headers_mut().insert( + header::HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("same-origin"), + ); + + let _ = handle_page_bids( + &settings, + &services, + Some(&graph), + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: Some(®istry), + }, + &mut ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_auction_used_the_canonical_row(&captured, &ec_context); + } } } diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 56306e364..d0d8c4be1 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -553,7 +553,7 @@ This lookup is implemented only by the Fastly adapter because the identity graph **Response fields:** -- `ec_id`, `store`, and `generation` identify the raw KV lookup. +- `ec_id` is the EC ID as requested, and `kv_key` is the identity-graph key the record was read from, which is the owning provider's canonical form of `ec_id` and the same string for an identifier the built-in HMAC provider issued. `store` and `generation` identify the raw KV lookup. - `entry` preserves the stored JSON shape, including unknown and legacy fields. Derived `created_iso` and `consent.updated_iso` fields are added only when absent. - `metadata` preserves the stored metadata JSON shape. - `tombstone` reports whether consent has been withdrawn. It is absent when the entry body cannot be parsed as JSON or deserialized as the typed EC schema. From fb1bc28d85d19d0dbbe6b81c45988941f9aa3a14 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:45:19 +0100 Subject: [PATCH 094/133] Correct comment claims and test a stale preload read Some comments added for the two review threads on provider response effects and canonical keying claimed more than the code does, and one changed comparison had no test that depended on it. The AcceptedProviders doc said pull sync, batch sync and the admin lookup all read and write rows, but the admin lookup only reads. The comment in the test a_rejected_provider_effect_never_reaches_the_finalized_response said a rejected header kept anywhere on the context would reach the browser, when EC finalization applies only the response headers the context holds. The admin lookup docs and the API reference entry for kv_key named the owning provider as the source of the row key, which does not hold for a deployment with no provider selected, where the built-in HMAC identifier format supplies the key. Three other comments now say precisely what they mean. The EcContext::kv_key_for doc names the function each reference points at, the EcContext::candidate_id comment no longer credits an unnamed caller with serving the response, and the dispatch_pull_sync doc names ec_hash as the input to the rate limit key. Before replacing the snapshot that generation bound, the publisher navigation preload compares that snapshot with its fresh read. The comparison is keyed by the canonical key, yet the navigation tests for a provider whose canonical key differs from the cookie value passed with it keyed by the identifier as issued, because their stores returned the row on the first read. The test for a newly created identifier's cookie now also runs against a store whose first point read misses the row generation just wrote. With the comparison keyed by the identifier as issued, the preload replaced the snapshot with that miss, EC finalization skipped the cookie and the test failed. With the canonical key the test passes. --- crates/trusted-server-core/src/ec/admin.rs | 18 ++- crates/trusted-server-core/src/ec/mod.rs | 34 ++-- crates/trusted-server-core/src/ec/provider.rs | 11 +- .../trusted-server-core/src/ec/pull_sync.rs | 4 +- crates/trusted-server-core/src/publisher.rs | 152 ++++++++++-------- docs/guide/api-reference.md | 2 +- 6 files changed, 121 insertions(+), 100 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index b651f0685..ded3e8544 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -199,8 +199,8 @@ pub fn deny_admin_diagnostic_fallback(req: &Request) -> Option) -> Result String /// second provider's identifiers can never be adopted or written under this /// deployment's keys. /// -/// All three read and write rows under the key +/// All three look rows up under the key /// [`canonical_kv_key`](Self::canonical_kv_key) returns rather than under the -/// identifier as given, so a provider whose canonical form differs from the -/// cookie value still reaches the row it created. Batch sync and the admin -/// lookup call it directly. Pull sync reaches it through `EcContext::kv_key_for` -/// and still sends partners the identifier as issued. +/// identifier as given, and pull sync and batch sync also write under that +/// key, so a provider whose canonical form differs from the cookie value still +/// reaches the row it created. Batch sync and the admin lookup call +/// `canonical_kv_key` directly. Pull sync calls `canonical_kv_key` through +/// `EcContext::kv_key_for` and still sends partners the identifier as issued. /// /// The set holds the deployment's active provider. The design's /// `legacy_providers` reader list, the providers that never create but must still diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 9220b983e..8d5f0cc42 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -99,8 +99,8 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Optionok".to_vec()); - let services = services_with( - Arc::clone(&stub) as Arc, - Arc::new(RecordingTelemetrySink::default()), - ); - let graph = KvIdentityGraph::in_memory("navigation-new-identifier-store"); - let consent = crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; - let mut ec_context = EcContext::new_for_test(None, consent) - .with_provider_for_test(Arc::new(CanonicalizingProvider)); - ec_context - .generate_if_needed(&settings, Some(&graph)) - .expect("should create the identifier through the provider"); - assert_eq!( - ec_context.ec_value(), - Some(CANONICAL_COOKIE_VALUE), - "test precondition: the provider should create its identifier" - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) - .header(header::HOST, EDGE_HOST) - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build test request"); + // this request had just created. The second store's first point + // read misses the row generation just wrote, as an eventually + // consistent store can right after a write. The preload keeps + // generation's snapshot in that case only when it compares both + // snapshots under the canonical key. + let stores = [ + ( + "a consistent store", + KvIdentityGraph::in_memory("navigation-new-identifier-store"), + ), + ( + "a store whose first point read misses", + KvIdentityGraph::stale_lookup("navigation-stale-read-store", 1), + ), + ]; + for (store, graph) in stores { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent) + .with_provider_for_test(Arc::new(CanonicalizingProvider)); + ec_context + .generate_if_needed(&settings, Some(&graph)) + .expect("should create the identifier through the provider"); + assert_eq!( + ec_context.ec_value(), + Some(CANONICAL_COOKIE_VALUE), + "test precondition: the provider should create its identifier" + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); - let _ = handle_publisher_request( - &settings, - &services, - Some(&graph), - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[], - registry: None, - }, - req, - EdgeCacheHeader::SMaxageFallback, - ) - .await - .expect("should proxy publisher request"); + let _ = handle_publisher_request( + &settings, + &services, + Some(&graph), + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); - let mut response = Response::new(EdgeBody::empty()); - crate::ec::finalize::ec_finalize_response( - &settings, - &mut ec_context, - Some(&graph), - &PartnerRegistry::empty(), - None, - None, - &mut response, - ); + let mut response = Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); - let cookies: Vec<&str> = response - .headers() - .get_all(header::SET_COOKIE) - .iter() - .filter_map(|value| value.to_str().ok()) - .collect(); - assert!( - cookies + let cookies: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) .iter() - .any(|cookie| cookie.starts_with("ts-ec=") && !cookie.contains("Max-Age=0")), - "the identifier this request created should reach the browser, got {cookies:?}" - ); + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && !cookie.contains("Max-Age=0")), + "the identifier this request created should reach the browser with {store}, \ + got {cookies:?}" + ); + } } #[tokio::test] diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index d0d8c4be1..f044f853f 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -553,7 +553,7 @@ This lookup is implemented only by the Fastly adapter because the identity graph **Response fields:** -- `ec_id` is the EC ID as requested, and `kv_key` is the identity-graph key the record was read from, which is the owning provider's canonical form of `ec_id` and the same string for an identifier the built-in HMAC provider issued. `store` and `generation` identify the raw KV lookup. +- `ec_id` is the EC ID as requested, and `kv_key` is the identity-graph key the record was read from. The key is `ec_id` in the normalized form the identity graph stores, which is the same string as `ec_id` for an identifier the built-in HMAC provider issued. `store` and `generation` identify the raw KV lookup. - `entry` preserves the stored JSON shape, including unknown and legacy fields. Derived `created_iso` and `consent.updated_iso` fields are added only when absent. - `metadata` preserves the stored metadata JSON shape. - `tombstone` reports whether consent has been withdrawn. It is absent when the entry body cannot be parsed as JSON or deserialized as the typed EC schema. From be0ed2dd061a7d67d8c14654b540a65e2dd29fe6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:11:59 +0100 Subject: [PATCH 095/133] Acknowledge one jurisdiction in the adapter startup tests Main's configuration-driven OpenRTB auction providers (#1016) added three startup tests each to the Cloudflare and Spin adapters, and their settings set the deprecated [ec] passphrase with no geo provider. That form migrates to provider = "hmac", and on this branch GeoConfig::validate_jurisdiction_acknowledgment requires [geo] assume_single_jurisdiction = true for an Edge Cookie provider with no geo provider, so the settings failed to load and all six tests failed. The four settings blocks now set it, as the other test settings in both adapters that select an Edge Cookie provider already do. --- crates/trusted-server-adapter-cloudflare/src/app.rs | 9 +++++++++ crates/trusted-server-adapter-spin/src/app.rs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7237471ce..50a2fbc97 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -811,6 +811,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -859,6 +862,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -903,6 +909,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 488bea531..933502bf7 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1020,6 +1020,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); From cb6f717b51b7840740c2dab2a3246768388de911 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:23:14 +0100 Subject: [PATCH 096/133] Keep provider headers only from candidates that generation commits A provider's own response headers, such as an evidence cookie, reached the browser even when generation discarded the candidate they came with. EcContext::candidate_id kept the headers before checking the identifier against the cookie bounds, and generate_with_provider left them on the context when the candidate collided with an existing row or its row could not be written. EC finalization applies whatever headers the context holds, and the publisher and integration proxies log a generation error and still serve the response, so the cookie went out with no identifier stored for it. candidate_id now keeps the headers only once the identifier has passed the bounds check, or when the provider produced no identifier at all, and generate_with_provider drops them with a colliding or unpersisted candidate. The reserved-surface check asked for in the review thread on provider response effects already ran before any header was kept, so the same rule now also holds when the identifier is rejected, when it collides and when its row cannot be written. A test covers each of those three cases, and all three failed before the change with the provider's cookie on the finalized response. HeaderSettingProvider now takes the identifier it returns, so a test can pair a permitted header with an identifier outside the cookie-safe alphabet. The finalization comment on applying provider headers now names candidate_id as the place they are checked, where it named generate_with_provider. The review thread is https://github.com/IABTechLab/trusted-server/pull/1043#discussion_r3882373907 --- crates/trusted-server-core/src/ec/finalize.rs | 2 +- crates/trusted-server-core/src/ec/mod.rs | 165 ++++++++++++++++-- 2 files changed, 153 insertions(+), 14 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 8d572ed76..15249201f 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -53,7 +53,7 @@ pub fn ec_finalize_response( // generation (for example to request more client evidence). This is empty // unless a provider produced headers, so it is safe on every path. Each // one was checked against core's reserved response surface at capture - // time in `EcContext::generate_with_provider`, so nothing here can set a + // time in `EcContext::candidate_id`, so nothing here can set a // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop // header. They accumulate with whatever the origin returned rather than // replacing it, for the reasons on diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c683173a0..906eea24f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -495,14 +495,14 @@ impl EcContext { })); } } - // Capture any response headers the provider asked for, even when it - // produced no identifier (for example while it still needs more client - // evidence). EC finalization applies them to the response. - self.response_headers = generated.response_headers; let generated_id = generated .id .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); let Some(ec_id) = generated_id else { + // Keep the response headers the provider asked for even though it + // produced no identifier (for example while it still needs more + // client evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; log::info!( "EC generation produced no identifier (provider={}); proceeding without an EC", ec_provider.id(), @@ -524,6 +524,11 @@ impl EcContext { ), })); } + // Keep the provider's response headers only now that its identifier + // has passed the bounds check, so a provider response whose identifier + // is rejected keeps none of them. EC finalization applies them to the + // response. + self.response_headers = generated.response_headers; log::info!( "Generated new EC ID (provider={}): {}", ec_provider.id(), @@ -545,6 +550,12 @@ impl EcContext { /// that key. The skip guards (existing EC, consent gate) stay in /// [`generate_if_needed`](Self::generate_if_needed). /// + /// The response headers a provider response asks for stay on the context + /// only when its identifier is committed or it produced no identifier at + /// all. A colliding candidate's headers are dropped before the next attempt + /// and nothing is kept when persisting fails, so EC finalization never + /// applies a header from a candidate this request discarded. + /// /// # Errors /// /// Forwards every error from [`candidate_id`](Self::candidate_id), and @@ -590,6 +601,10 @@ impl EcContext { }; } Ok(CreateIfAbsentOutcome::AlreadyExists) => { + // The colliding candidate is discarded, and the + // response headers its provider response asked for + // are discarded with it. + self.response_headers.clear(); log::warn!( "Generated EC ID collision on attempt {}/{MAX_CREATE_ATTEMPTS}", attempt + 1 @@ -597,6 +612,9 @@ impl EcContext { continue; } Err(err) => { + // Nothing is committed, so none of the provider's + // response headers are kept either. + self.response_headers.clear(); log::error!( "Failed to create EC entry for id '{}' after generation: {err:?}", log_id(&ec_id), @@ -1613,14 +1631,14 @@ pub(crate) mod tests { ); } - /// A provider that returns a caller-chosen response header and no - /// identifier, so a test can drive one provider response effect at a time - /// through the organic generate path. + /// A provider that returns a caller-chosen response header, and `id` as its + /// identifier when one is given, so a test can drive one provider response + /// effect at a time through the organic generate path. #[derive(Debug)] struct HeaderSettingProvider { name: &'static str, value: &'static str, - mint: bool, + id: Option<&'static str>, } impl EdgeCookieProvider for HeaderSettingProvider { @@ -1638,7 +1656,7 @@ pub(crate) mod tests { _input: &IdentityInput<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { - id: self.mint.then(|| "provider-value".to_owned()), + id: self.id.map(str::to_owned), response_headers: vec![( http::HeaderName::from_bytes(self.name.as_bytes()) .expect("should parse header name"), @@ -1691,7 +1709,7 @@ pub(crate) mod tests { HeaderSettingProvider { name: "set-cookie", value: "ts-ec=forged-value; Path=/", - mint: false, + id: None, }, None, ); @@ -1709,7 +1727,7 @@ pub(crate) mod tests { HeaderSettingProvider { name, value, - mint: false, + id: None, }, None, ); @@ -1738,7 +1756,7 @@ pub(crate) mod tests { HeaderSettingProvider { name, value, - mint: true, + id: Some("provider-value"), }, Some(&graph), ); @@ -1800,7 +1818,7 @@ pub(crate) mod tests { HeaderSettingProvider { name: "set-cookie", value: "acme-evidence=abc123; Path=/; Secure", - mint: true, + id: Some("provider-value"), }, Some(&graph), ); @@ -1843,6 +1861,127 @@ pub(crate) mod tests { ); } + /// A cookie a provider may set for itself, used to check which provider + /// responses keep their headers. + const PROVIDER_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; + + /// Runs EC finalization on `ec` for an empty response and returns the + /// `Set-Cookie` values the response carries. + fn finalized_set_cookies( + settings: &Settings, + ec: &mut EcContext, + graph: &KvIdentityGraph, + ) -> Vec { + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + settings, + ec, + Some(graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|cookie| cookie.to_str().ok()) + .map(str::to_owned) + .collect() + } + + #[test] + fn a_rejected_identifier_keeps_none_of_its_provider_response_headers() { + // The provider's own cookie is permitted, but the identifier in the + // same provider response is outside the cookie-safe alphabet, so + // generation returns an error and nothing from that response may reach + // the browser. + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, mut ec, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: PROVIDER_EVIDENCE_COOKIE, + id: Some("bad;value with spaces"), + }, + Some(&graph), + ); + + assert!( + outcome.is_err(), + "an identifier outside the cookie-safe alphabet should make generation return an error" + ); + let cookies = finalized_set_cookies(&settings, &mut ec, &graph); + assert!( + !cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=")), + "the rejected provider response's cookie should not reach the response, got: {cookies:?}" + ); + } + + #[test] + fn a_colliding_candidates_provider_headers_never_reach_the_response() { + // Every candidate collides with a row the graph already holds, so each + // one is discarded and generation returns an error once the retries + // run out. The cookie each provider response asked for belongs to a + // discarded candidate and must not reach the browser. + let graph = KvIdentityGraph::new(AddCollidingEcKv::new(u32::MAX)); + let (settings, mut ec, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: PROVIDER_EVIDENCE_COOKIE, + id: Some("provider-value"), + }, + Some(&graph), + ); + + assert!( + outcome.is_err(), + "exhausting the collision retries should make generation return an error" + ); + let cookies = finalized_set_cookies(&settings, &mut ec, &graph); + assert!( + !cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=")), + "a colliding candidate's cookie should not reach the response, got: {cookies:?}" + ); + } + + #[test] + fn an_unpersisted_candidate_keeps_none_of_its_provider_response_headers() { + // The identity graph cannot store the candidate's row, so no + // identifier is committed and the cookie its provider response asked + // for must not reach the browser either. + let graph = KvIdentityGraph::new(crate::ec::kv_backend::test_support::FailingEcKv::new( + "failing-ec-store", + )); + let (settings, mut ec, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: PROVIDER_EVIDENCE_COOKIE, + id: Some("provider-value"), + }, + Some(&graph), + ); + + assert!( + outcome.is_err(), + "a failed identity-graph write should make generation return an error" + ); + let cookies = finalized_set_cookies(&settings, &mut ec, &graph); + assert!( + !cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=")), + "an unpersisted candidate's cookie should not reach the response, got: {cookies:?}" + ); + } + /// A provider whose identifier normalizes to a distinct canonical form, to /// prove the identity graph is keyed by the canonical form. /// From 1b88e42f7f96f7f88d7c8e874bf87704ce6996b0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:23:15 +0100 Subject: [PATCH 097/133] Correct the admin lookup forms and the bare reader row note The API reference said the explicit admin EC lookup route accepts an EC ID in the bare {64 lowercase hex}.{6 alphanumeric} form. The route accepts whatever AcceptedProviders::canonical_kv_key accepts, which is an identifier created by the selected provider, such as the built-in HMAC provider's hmac~ form, and the bare legacy form that provider still reads, with both HMAC forms accepted when no provider is selected. The hash may be given in either case, because canonical_kv_key lowercases it before the check. The note on retiring the legacy bare reader said a page view with ts-eids or sharedId cookies runs ingest_eid_cookies in ec_finalize_response and so restarts the row's one-year clock. Main's change threading the EC KV read through the request (#885) moved finalization to collect_eid_cookie_updates and upsert_partner_ids_from_snapshot, which writes nothing unless a partner ID is added or changed. The note now names that function and says only such a view restarts the clock. --- crates/trusted-server-core/src/ec/provider.rs | 21 ++++++++++--------- docs/guide/api-reference.md | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index f3e349b7c..07698aca5 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -452,16 +452,17 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { /// visitor's bare cookie is never rewritten into the coded form, and its /// `COOKIE_MAX_AGE` lifetime in [`cookies`](super::cookies) (one year, not /// operator-configurable) runs from the moment it was written. The -/// identity-graph row is not fixed the same way: an ordinary page view that -/// ingests `ts-eids` or `sharedId` cookies runs `ingest_eid_cookies` in -/// `ec_finalize_response` (see [`finalize`](super::finalize)), which rewrites -/// the bare-keyed row with a fresh `ENTRY_TTL` in [`kv`](super::kv) (also one -/// year), so the row's clock restarts on each such view. The earliest safe -/// retirement is therefore one year after the last write that could still -/// leave a bare-keyed row, which is the later of the last release that could -/// still create a bare identifier stopping everywhere and the last page view -/// that refreshed such a row, plus however long a deployment's own rollout -/// takes to reach every point of presence. +/// identity-graph row is not fixed the same way. When the `ts-eids` or +/// `sharedId` cookies on an ordinary page view add or change a partner ID in +/// the row, `ec_finalize_response` (see [`finalize`](super::finalize)) writes +/// the bare-keyed row back through `upsert_partner_ids_from_snapshot` with a +/// fresh `ENTRY_TTL` in [`kv`](super::kv) (also one year), so the row's clock +/// restarts on each such view. The earliest safe retirement is therefore one +/// year after the last write that could still leave a bare-keyed row, which +/// is the later of the last release that could still create a bare +/// identifier stopping everywhere and the last page view that refreshed such +/// a row, plus however long a deployment's own rollout takes to reach every +/// point of presence. /// /// The other half of that condition, evidence that bare identifiers really /// have stopped arriving, cannot be checked today. Nothing counts or logs a diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f044f853f..158da3b15 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -547,7 +547,7 @@ The examples below use fictional IDs and values only. ### GET /\_ts/admin/ec/`{id}` -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format. The bare route uses the request's `ts-ec` cookie. +Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID created by the provider this deployment selects, such as the built-in HMAC provider's `hmac~{64 hex}.{6 alphanumeric}` form. The built-in HMAC provider also still reads the bare legacy `{64 hex}.{6 alphanumeric}` form, and a deployment with no provider selected accepts both of those forms. The bare route uses the request's `ts-ec` cookie. This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. From 88a7482f81cd252b8cd275ee762c069b9028cc2a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 21:27:38 +0100 Subject: [PATCH 098/133] Run the permission signal providers in the permissions inspector The inspector calls `assemble_permissions`, which this branch later gave two more arguments, the request evidence and the signal providers. Nothing caught it, because `tools/permissions-inspector/wasm` is deliberately its own workspace, so the gates that build the main workspace never build it, and the `Permissions Inspector` workflow that does is added by this branch too and so first ran on this pull request. It failed with three E0061 errors. The inspector now builds the same four providers every adapter offers, in the same default order (`gpc`, `gpp-sale-opt-out`, `us-privacy`, `tcf`), and hands them to `assemble_permissions`. Passing an empty slice would have compiled just as well and silently stopped the page acting on any signal at all, which is the one thing the page exists to show. The evidence argument is an empty `OwnedRequestInfo`. The page carries no request, only the consent signals its form collects, and each of the four providers answers from the consent record rather than from request evidence, so empty evidence changes none of their answers. The comment in `eval_json` says so, and says that a provider reading a header or a cookie would need real evidence there. The lock file picks up the four crates, and with them the edgezero v0.0.7 to v0.0.8 bump and `cssparser`, which this separate lock had not taken from the `main` merge. `build.rs` and one line of `src/lib.rs` are rustfmt output. The crate is outside the workspace, so `cargo fmt --all` never reached it and `build.rs` had never been formatted. Tests. `./scripts/build-inspector-wasm.sh` builds clean for wasm32-unknown-unknown, and `cargo fmt --check` on the crate is clean. --- tools/permissions-inspector/wasm/Cargo.lock | 37 +++++++++++++++++++-- tools/permissions-inspector/wasm/Cargo.toml | 6 ++++ tools/permissions-inspector/wasm/build.rs | 15 +++++++-- tools/permissions-inspector/wasm/src/lib.rs | 31 ++++++++++++++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock index 555b4e912..97123760b 100644 --- a/tools/permissions-inspector/wasm/Cargo.lock +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" dependencies = [ "anyhow", "async-compression", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" dependencies = [ "log", "proc-macro2", @@ -1322,6 +1322,10 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -2038,6 +2042,7 @@ dependencies = [ "chacha20poly1305", "chrono", "cookie", + "cssparser", "derive_more", "ed25519-dalek", "edgezero-core", @@ -2092,6 +2097,34 @@ dependencies = [ "serde_json", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml index f212c89db..dee34a3cf 100644 --- a/tools/permissions-inspector/wasm/Cargo.toml +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -9,6 +9,12 @@ crate-type = ["cdylib"] [dependencies] trusted-server-core = { path = "../../../crates/trusted-server-core" } +# The inspector runs the same permission signal providers an adapter ships, +# in the same default order, so the page answers as a deployment would. +trusted-server-permission-signal-gpc = { path = "../../../crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "../../../crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "../../../crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "../../../crates/permission-signal/us-privacy" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs index 65abf8bda..5e8f11cb3 100644 --- a/tools/permissions-inspector/wasm/build.rs +++ b/tools/permissions-inspector/wasm/build.rs @@ -30,8 +30,17 @@ fn workspace_version() -> String { fn main() { println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); - println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); - println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); - println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!( + "cargo:rustc-env=TS_CORE_COMMIT={}", + git(&["rev-parse", "--short=9", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_DATE={}", + git(&["show", "-s", "--format=%cs", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_BRANCH={}", + git(&["rev-parse", "--abbrev-ref", "HEAD"]) + ); println!("cargo:rerun-if-changed=../../../Cargo.toml"); } diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index 928df80b1..b5be589af 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -3,14 +3,29 @@ //! functions the server runs: `build_context_from_signals` decodes the raw //! consent signals and `assemble_permissions` resolves the policy. +use std::sync::Arc; + use serde::Deserialize; use serde_json::json; use trusted_server_core::consent::build_context_from_signals; use trusted_server_core::consent::types::RawConsentSignals; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::PermissionSignalProvider; use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; +/// The signal providers the inspector asks, in the order every adapter offers +/// them when `[permission_signal] sources` names none. +fn providers() -> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + /// The inspector's evaluation request. #[derive(Deserialize)] struct EvalInput { @@ -38,14 +53,20 @@ fn eval_json(input: &str) -> String { gpc: input.gpc, }; let ctx = build_context_from_signals(&signals); + // The page carries no request, only the consent signals above, and each of + // the four providers answers from the consent record rather than from + // request evidence, so empty evidence changes none of their answers. A + // provider that read a header or a cookie would need real evidence here. + let evidence = OwnedRequestInfo::default(); + let providers = providers(); let maps = PermissionMaps::standard(); let (state, jurisdiction) = match input.geo.as_str() { "failed" => { - let state = assemble_permissions(&ctx, GeoStatus::Failed); + let state = assemble_permissions(&ctx, &evidence, GeoStatus::Failed, &providers); (state, "unknown".to_string()) } "none" => { - let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + let state = assemble_permissions(&ctx, &evidence, GeoStatus::NoLocation, &providers); (state, jurisdiction_name(maps.default_jurisdiction())) } _ => { @@ -59,7 +80,8 @@ fn eval_json(input: &str) -> String { region: input.region.clone().filter(|r| !r.is_empty()), asn: None, }; - let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let state = + assemble_permissions(&ctx, &evidence, GeoStatus::Located(&info), &providers); let jurisdiction = jurisdiction_name( maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), ); @@ -83,7 +105,8 @@ fn eval_json(input: &str) -> String { fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { let name = format!("{j:?}").to_lowercase(); let name = name.split('(').next().unwrap_or(&name).to_string(); - name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") + name.replace("usstate", "us-state") + .replace("nonregulated", "non-regulated") } fn validate_json(yaml: &str) -> String { From a2a1dfce78e9483c0530d9f999809127561ed7f8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 23:17:22 +0100 Subject: [PATCH 099/133] Name the permissions sample for what it is Renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml` and says at the top of the file, in the guide and on the constant that the file is for testing and evaluation only, is not a production policy and is not legal advice. "Vanilla" described the flavour of the rules and said nothing about whether anyone should run them, which is the thing a reader needs to know first. The display name becomes "Sample (testing and evaluation only)", so the inspector's dropdown carries the warning wherever the page is opened. The build script globs `config/permissions/*.yaml`, so its manifest picks the new name up with no change. `include_str!` in `permissions.rs` follows the rename. The doc comment above it is rewrapped and loses a clause-joining semicolon. The guide gains a short paragraph saying the same thing. The word "vanilla" is left alone where it means plain JavaScript, in the DataDome and Lockr script guards and in an older plan document. Tests. `cargo fmt --all --check` is clean, the core suite passes 2,851 tests, and `./scripts/build-inspector-wasm.sh` builds and writes a manifest reading `{"file":"sample.yaml","name":"Sample (testing and evaluation only)"}`. The docs Prettier configuration sets `proseWrap: preserve`, so the shorter path cannot change the formatting. --- config/permissions/{vanilla.yaml => sample.yaml} | 15 ++++++++++----- crates/trusted-server-core/src/permissions.rs | 9 ++++++--- docs/guide/permission-model.md | 5 ++++- trusted-server.example.toml | 2 +- 4 files changed, 21 insertions(+), 10 deletions(-) rename config/permissions/{vanilla.yaml => sample.yaml} (97%) diff --git a/config/permissions/vanilla.yaml b/config/permissions/sample.yaml similarity index 97% rename from config/permissions/vanilla.yaml rename to config/permissions/sample.yaml index dfb0452bd..a5c19c8f7 100644 --- a/config/permissions/vanilla.yaml +++ b/config/permissions/sample.yaml @@ -9,13 +9,18 @@ # vocabulary this project invented, so a reader can check it against the # taxonomy rather than against us. # +# This file is a sample for testing and evaluation only. It is not a +# production policy, it is not legal advice, and no deployment should run it +# as it stands. Read it, change it, or replace it, and have whoever is +# accountable for the deployment decide what it should say. +# # No policy ever ships with Trusted Server. The builder of a deployment # chooses the permissions.yaml compiled into their image, an operator overlay # is the recorded follow-on, and the visitor's signals decide the rest at -# runtime. This copy is the repository's plain vanilla sample, compiled into -# test and demo builds so the rules stay visible and reviewable in version -# control. The samples live in config/permissions, and choosing one is always -# an explicit choice by whoever builds, configures, or inspects. +# runtime. This copy is compiled into test and demo builds so the rules stay +# visible and reviewable in version control. The samples live in +# config/permissions, and choosing one is always an explicit choice by whoever +# builds, configures, or inspects. # # There are three parts: # groups named permission baselines, defined once and referenced by rules @@ -106,7 +111,7 @@ # Named baselines. Each group lists every Data Use and its flag, so a group's # meaning is explicit. (A `default: ` shorthand is also accepted for any # Data Use a group omits.) -name: Vanilla +name: Sample (testing and evaluation only) groups: # European Union and EEA, where the model is opt-in, so every modeled ad-tech diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index b888a47fd..ff2e23d29 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -475,9 +475,12 @@ pub struct PermissionMaps { } /// The default permission rules, compiled into the build from the repository's -/// vanilla sample in `config/permissions`. A deployer chooses or replaces the -/// compiled-in file to set the default policy; it is not read at runtime. -const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/vanilla.yaml"); +/// sample in `config/permissions`. +/// +/// That sample is for testing and evaluation only and is not a production +/// policy. A deployer chooses or replaces the compiled-in file to set the +/// default policy, which is not read at runtime. +const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/sample.yaml"); /// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. fn region_key(country: &str, region: &str) -> String { diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 5a34f7aeb..32b0278ee 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -185,8 +185,11 @@ The Edge Cookie `Set-Cookie` operation always requires `necessary.operations.sto ## Groups and rules -The policy lives in a human-editable permissions YAML document. The repository sample is `config/permissions/vanilla.yaml`, +The policy lives in a human-editable permissions YAML document. The repository sample is `config/permissions/sample.yaml`, compiled into the build, so policy owners read and change it in version control. +That sample is for testing and evaluation only. It is not a production policy +and it is not legal advice, so a deployment reads it, changes it, or replaces +it, with whoever is accountable for that deployment deciding what it says. It has two parts. **Groups** are named baselines, each a set of permission flags. **Rules** are a single tree that says which group applies where. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e41a44ff7..4fef496da 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -141,7 +141,7 @@ pull_sync_concurrency = 3 # The permission baseline for a request the geo provider leaves unmatched # (and, with no geo provider, for every request) is the top of the rules tree # in the permissions.yaml compiled into the build (the repository sample is -# config/permissions/vanilla.yaml). Edit that file to +# config/permissions/sample.yaml). Edit that file to # change it. # # With no geo provider, every request is treated as that top node, so a From f5b9c04af8a6ed1c036aadf1763fe5a95411818f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 13:14:18 +0100 Subject: [PATCH 100/133] Select permission signal providers with provider, named in snake_case Every provider type selects the same way, with provider in its own section, and names its providers in snake_case. Permission signals now follow that, so [permission_signal] sources becomes [permission_signal] provider. The key stays an ordered list, and a configuration that leaves it out still runs every provider the adapter links, in the order the adapter offers them. The two hyphenated provider names become gpp_sale_opt_out and us_privacy, while gpc and tcf are unchanged. The crates report the new identifiers, and core's selection messages, the startup log, the module README, the guides, the example configuration and the permissions sample follow. sources is removed rather than accepted alongside provider, which breaks a configuration written against the branch that introduced it. A section carrying sources is refused when the settings are read, with a message naming provider, on each path a deployment reads settings through, being a TOML file, the TOML value ts config push reads, and the JSON config blob read at startup. Reading the section by hand rather than with deny_unknown_fields is what makes that message possible, because a derived refusal can only name sources by declaring it as a field, and would then offer it as a key it expects whenever it refused any other. No provider takes settings, so a [permission_signal.] block stays an unknown field. Its refusal now says that a provider which gains settings will take them there. Tests cover the removed key on each of those paths, a settings block refused as an unknown field, a list surviving a config blob round trip, and, where the real crates are linked, the documented names selecting all four providers in order and an old hyphenated name being refused with the names available. --- AGENTS.md | 4 +- config/permissions/sample.yaml | 4 +- crates/permission-signal/gpc/src/lib.rs | 2 +- crates/permission-signal/gpp/src/lib.rs | 4 +- crates/permission-signal/tcf/src/lib.rs | 2 +- .../permission-signal/us-privacy/src/lib.rs | 4 +- crates/trusted-server-adapter-axum/src/app.rs | 2 +- .../tests/permission_signals.rs | 52 ++++- .../src/app.rs | 2 +- .../trusted-server-adapter-fastly/src/app.rs | 2 +- crates/trusted-server-adapter-spin/src/app.rs | 2 +- .../src/permission_signal/README.md | 26 ++- .../src/permission_signal/mod.rs | 27 +-- crates/trusted-server-core/src/permissions.rs | 2 +- crates/trusted-server-core/src/settings.rs | 178 ++++++++++++++++-- docs/guide/permission-model.md | 2 +- docs/guide/permission-signals.md | 27 ++- tools/permissions-inspector/wasm/src/lib.rs | 2 +- trusted-server.example.toml | 26 ++- 19 files changed, 289 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7972540e8..cb7cbcb25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -365,7 +365,7 @@ deployment selects an implementation and the core stays neutral: | Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | | Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | | Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | -| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] sources` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] provider` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | Principles for adding or changing a provider: @@ -424,7 +424,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] sources` list) | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] provider` list) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/config/permissions/sample.yaml b/config/permissions/sample.yaml index a5c19c8f7..165fe80d9 100644 --- a/config/permissions/sample.yaml +++ b/config/permissions/sample.yaml @@ -513,7 +513,7 @@ rules: # Data Use is the TCF scheme's knowledge and lives in the TCF permission signal # provider crate, crates/permission-signal/tcf, so a deployment that runs no # TCF carries no table of another scheme's numbers. Which schemes run at all, -# and in what order, is [permission_signal] sources in trusted-server.toml. +# and in what order, is [permission_signal] provider in trusted-server.toml. signals: # A present TCF v2 record (a standalone TC string, or the EU TCF section of a # GPP string). With authoritative true, the record's consent to a purpose @@ -521,7 +521,7 @@ signals: # revokes them, whereas with authoritative false the record is ignored. # The flag governs only whether the record answers. Whether its answer # stands over an opt-out signal below, or the opt-out over it, is the order - # the providers are asked in, [permission_signal] sources in + # the providers are asked in, [permission_signal] provider in # trusted-server.toml, where the last provider with an opinion decides. The # default order asks Global Privacy Control first, being a browser setting # with no interface of its own, and the schemes carrying a choice someone diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index 1f05fdc25..e2ef6b643 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -13,7 +13,7 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. +/// `provider`, in logs, and when a peer consults it. pub const ID: &str = "gpc"; /// The `Sec-GPC` request header, Global Privacy Control. diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index e5b402e66..e77e66e69 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -11,8 +11,8 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. -pub const ID: &str = "gpp-sale-opt-out"; +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "gpp_sale_opt_out"; /// A GPP US sale opt-out, read from the `__gpp` string. #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 2229c9980..a73c5473e 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -23,7 +23,7 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. +/// `provider`, in logs, and when a peer consults it. pub const ID: &str = "tcf"; /// TCF v2, when the policy says TCF answers for this deployment. diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index 9ef4316cb..80bc49a1a 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -14,8 +14,8 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. -pub const ID: &str = "us-privacy"; +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "us_privacy"; /// A US Privacy string sale opt-out, read from `us_privacy`. #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 01a569601..1d78523fb 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -52,7 +52,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 99ec15022..44d06e35d 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -8,12 +8,13 @@ //! left off the list not running at all, and withdrawal being TCF's alone //! and scoped to the place. This sits in the Axum adapter's tests because //! it is the first crate that links all four, and core deliberately links -//! none. +//! none. The names a deployment writes in configuration are checked here for +//! the same reason, against the identifiers the real crates answer to. //! //! The consent records here are built by hand, so nothing in core's consent //! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy //! opt-out from a Global Privacy Control header in a US state when the consent -//! settings say to, and the `us-privacy` provider then acts on it, which is +//! settings say to, and the `us_privacy` provider then acts on it, which is //! why removing `gpc` from the list alone does not make that header inert. use std::sync::Arc; @@ -43,14 +44,19 @@ fn all_four() -> Vec> { ] } +/// Settings naming these identifiers in `[permission_signal] provider`. +fn settings_naming(names: &[&str]) -> Settings { + let mut settings = Settings::default(); + settings.permission_signal.provider = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + settings +} + /// The providers a deployment gets from naming these identifiers in -/// `[permission_signal] sources`, through the same entry point an adapter's +/// `[permission_signal] provider`, through the same entry point an adapter's /// composition root uses. fn configured(names: &[&str]) -> Arc<[Arc]> { - let mut settings = Settings::default(); - settings.permission_signal.sources = - Some(names.iter().map(|name| (*name).to_owned()).collect()); - build_permission_signal_providers(&settings, &all_four()) + build_permission_signal_providers(&settings_naming(names), &all_four()) .expect("should select providers this build offers") } @@ -139,6 +145,38 @@ fn us_ca_geo() -> GeoInfo { // Which providers run. // ---------------------------------------------------------------------- +#[test] +fn the_documented_names_select_every_shipped_provider_in_order() { + // The names the guide and the example configuration list, which must be + // the identifiers the shipped crates answer to. + let documented = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"]; + let selected: Vec<&str> = configured(&documented) + .iter() + .map(|provider| provider.id()) + .collect(); + assert_eq!( + selected, documented, + "each documented name selects the shipped provider it names, in the order written" + ); +} + +#[test] +fn an_old_hyphenated_name_is_refused_naming_the_names_available() { + for old in ["gpp-sale-opt-out", "us-privacy"] { + let Err(error) = build_permission_signal_providers(&settings_naming(&[old]), &all_four()) + else { + panic!("should refuse the hyphenated name `{old}`"); + }; + let message = format!("{error:?}"); + assert!( + message.contains(&format!("`{old}` is not available in this build")) + && message + .contains("Available providers are gpc, gpp_sale_opt_out, us_privacy, tcf"), + "the refusal names the old name and the names to write instead: {message}" + ); + } +} + #[test] fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { // A US-style opt-out drops a granted baseline, because the map granted diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 50a2fbc97..4544948ca 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -83,7 +83,7 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index fefcece1d..cad5bf88c 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -195,7 +195,7 @@ pub(crate) struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. pub(crate) ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 933502bf7..214c85784 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -78,7 +78,7 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 42e2d01ef..e7034b2d0 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -78,7 +78,7 @@ a deployment's to set, not this code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] ``` A provider not on the list does not run, and there is no separate switch. A @@ -86,12 +86,19 @@ publisher who does not want to act on Global Privacy Control removes `"gpc"` from the list, and the provider that reads the header then does not run. One caveat: core's consent pipeline can also synthesize a US Privacy opt-out from that header for a visitor in a US state, when the consent settings say to, -which they do by default, and the `us-privacy` provider then acts on the +which they do by default, and the `us_privacy` provider then acts on the record it produced. A publisher who wants the header to have no effect at all -turns that setting off as well. Leaving the section out entirely runs every -provider the adapter offers, in the order it offers them, so a signal is never -quietly ignored because someone forgot to list it. An unknown or repeated name -is refused at startup, so a typo cannot silently stop a scheme being honored. +turns that setting off as well. Leaving `provider` out, or the section +entirely, runs every provider the adapter offers, in the order it offers them, +so a signal is never quietly ignored because someone forgot to list it. An +unknown or repeated name is refused at startup, so a typo cannot silently stop +a scheme being honored. + +Providers are named in `snake_case`, lowercase words joined by underscores. A +provider that gains settings will take them in a `[permission_signal.]` +block named for it. None of the four here has settings, so `provider` is the +only key the section accepts, and a block or any other key is refused as an +unknown field rather than ignored. The default order asks the signal with no interface of its own first and the ones carrying a choice made through an interface after. Global Privacy @@ -157,7 +164,8 @@ the identifier never depended on the record. ## Writing a provider -Implement `PermissionSignalProvider` in a crate that depends on core. Answer +Implement `PermissionSignalProvider` in a crate that depends on core, and give +it an identifier in `snake_case`, which is the name configuration uses. Answer `Neutral` for a permission the provider has no opinion on, including when the signal it reads is absent from the request. Returning `Revoke` for an absent signal turns silence into refusal and would revoke the permission on every @@ -188,8 +196,8 @@ configuring nothing gets all four in this order: | Identifier | Crate | Reads | | ------------------ | ------------ | ------------------------------------------------- | | `gpc` | `gpc` | The `Sec-GPC` header, Global Privacy Control | -| `gpp-sale-opt-out` | `gpp` | The US sale opt-out in a GPP string | -| `us-privacy` | `us-privacy` | The sale opt-out in a US Privacy string | +| `gpp_sale_opt_out` | `gpp` | The US sale opt-out in a GPP string | +| `us_privacy` | `us-privacy` | The sale opt-out in a US Privacy string | | `tcf` | `tcf` | A TCF v2 record, with its purpose mapping in code | The three opt-outs are separate rather than one so that a publisher who does diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index f30871fa1..24545dd71 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -146,6 +146,9 @@ impl<'a> SignalInput<'a> { pub trait PermissionSignalProvider: Send + Sync { /// Stable identifier, used in configuration, in logs, and by a peer /// looking this provider up through [`SignalInput::ask`]. + /// + /// Written in `snake_case`, lowercase words joined by underscores, for + /// example `gpp_sale_opt_out`. fn id(&self) -> &'static str; /// How this provider would amend `permission` for this request. @@ -314,7 +317,7 @@ pub(crate) fn select( return Err(Report::new(TrustedServerError::Configuration { message: format!( "Permission signal provider `{name}` is named more than once in \ - [permission_signal] sources. Each provider runs once, at one place \ + [permission_signal] provider. Each provider runs once, at one place \ in the order" ), })); @@ -373,16 +376,16 @@ pub fn build_permission_signal_providers( settings: &Settings, available: &[Arc], ) -> Result]>, Report> { - let configured = settings.permission_signal.sources.as_deref(); + let configured = settings.permission_signal.provider.as_deref(); let selected = select(available, configured)?; match configured { None => log::info!( "Permission signals: acting on every provider this build offers, [{}], no \ - [permission_signal] sources configured", + [permission_signal] provider configured", ids(&selected).join(", ") ), Some([]) => log::info!( - "Permission signals: acting on no provider, [permission_signal] sources is \ + "Permission signals: acting on no provider, [permission_signal] provider is \ empty, so every permission stays at its country and region baseline" ), Some(_) => log::info!( @@ -394,7 +397,7 @@ pub fn build_permission_signal_providers( if !left_out.is_empty() { log::warn!( "Permission signals: not acting on [{}], which are not in [permission_signal] \ - sources. A signal this deployment does not act on is read from the request \ + provider. A signal this deployment does not act on is read from the request \ and then ignored", left_out.join(", ") ); @@ -875,8 +878,8 @@ mod tests { fn four() -> Vec> { vec![ fixed("gpc", ConsentSignal::Neutral), - fixed("gpp-sale-opt-out", ConsentSignal::Neutral), - fixed("us-privacy", ConsentSignal::Neutral), + fixed("gpp_sale_opt_out", ConsentSignal::Neutral), + fixed("us_privacy", ConsentSignal::Neutral), fixed("tcf", ConsentSignal::Neutral), ] } @@ -886,7 +889,7 @@ mod tests { let selected = select(&four(), None).expect("should accept no configuration"); assert_eq!( ids(&selected), - vec!["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"], + vec!["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"], "a publisher who configures nothing acts on every scheme the build knows, so \ one is never ignored because they forgot to list it" ); @@ -894,11 +897,11 @@ mod tests { #[test] fn the_configured_order_is_the_order_they_run_in() { - let reversed = names(&["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"]); + let reversed = names(&["tcf", "us_privacy", "gpp_sale_opt_out", "gpc"]); let selected = select(&four(), Some(&reversed)).expect("should accept known names"); assert_eq!( ids(&selected), - vec!["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"], + vec!["tcf", "us_privacy", "gpp_sale_opt_out", "gpc"], "the list is the order, not merely the membership" ); } @@ -923,7 +926,7 @@ mod tests { }; let message = format!("{error:?}"); assert!( - message.contains("not-a-provider") && message.contains("gpc, gpp-sale-opt-out"), + message.contains("not-a-provider") && message.contains("gpc, gpp_sale_opt_out"), "the refusal names the bad entry and what is available: {message}" ); } @@ -944,7 +947,7 @@ mod tests { let configured = names(&["gpc", "tcf"]); assert_eq!( omitted(&four(), Some(&configured)), - vec!["gpp-sale-opt-out", "us-privacy"], + vec!["gpp_sale_opt_out", "us_privacy"], "the providers the list leaves out are reported in the offered order" ); assert!( diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index ff2e23d29..d7933773c 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -392,7 +392,7 @@ pub struct SignalPolicy { /// Whether a present TCF record's grants and revokes apply. Whether a /// consenting record then stands over an opt-out, or the opt-out over it, /// is decided by the order the providers are asked in, which is - /// `[permission_signal] sources`, not by this flag. + /// `[permission_signal] provider`, not by this flag. tcf_authoritative: bool, /// The signals that constitute a US-style opt-out. opt_out_sources: Vec, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2b37f1e4d..e013f4f16 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -982,18 +982,23 @@ impl DeviceConfig { /// Which permission signal providers run, and in what order. /// -/// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, -/// `[geo]` and `[device]` selectors, which each name one provider, signals -/// compose: a request can carry a TCF string and a Global Privacy Control -/// header at once and both have something to say. So this names a list, and -/// the order is the policy, because the last provider with an opinion decides. +/// Mapped from the `[permission_signal]` TOML section, where `provider` +/// selects, as it does in `[ec]`, `[geo]` and `[device]`. Those each name one +/// provider, whereas signals compose, because a request can carry a TCF string +/// and a Global Privacy Control header at once and both have something to say. +/// So here `provider` names a list, and the order is the policy, because the +/// last provider with an opinion decides. +/// +/// A provider that gains settings will take them in a +/// `[permission_signal.]` block named for it. None of the providers that +/// ship has settings, so `provider` is the only key accepted, and any other key +/// is refused as an unknown field rather than silently ignored. /// /// See `crates/trusted-server-core/src/permission_signal/README.md`. -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Validate)] pub struct PermissionSignalConfig { /// The providers to run, in order, named by the identifier each provider - /// crate declares, for example `gpc`, `gpp-sale-opt-out`, `us-privacy` and + /// crate declares, for example `gpc`, `gpp_sale_opt_out`, `us_privacy` and /// `tcf` for the four that ship. /// /// Absent means every provider the adapter offers, in the order it offers @@ -1010,8 +1015,41 @@ pub struct PermissionSignalConfig { /// /// [`build_permission_signal_providers`]: /// crate::permission_signal::build_permission_signal_providers - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sources: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option>, +} + +/// Read by hand rather than derived, so that `sources`, the key `provider` +/// replaced, is refused with a message saying what to write instead. A derived +/// struct could only refuse it by name by declaring it as a field, and would +/// then list it among the keys it expects whenever it refused any other. +impl<'de> Deserialize<'de> for PermissionSignalConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut section = serde_json::Map::::deserialize(deserializer)?; + if section.contains_key("sources") { + return Err(serde::de::Error::custom( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead", + )); + } + if let Some(key) = section.keys().find(|key| key.as_str() != "provider") { + return Err(serde::de::Error::custom(format!( + "unknown field `{key}` in [permission_signal], expected `provider`. No \ + permission signal provider takes settings yet, so a \ + [permission_signal.] block is not accepted" + ))); + } + // Read as an option, so an explicit JSON null is the same as leaving + // the key out. + let provider = match section.remove("provider") { + Some(value) => serde_json::from_value(value).map_err(serde::de::Error::custom)?, + None => None, + }; + Ok(Self { provider }) + } } /// Geo / IP intelligence configuration. @@ -8758,17 +8796,30 @@ formats = [{{ width = 300, height = 250 }}] #[cfg(test)] mod permission_signal_config_tests { use super::*; + use serde_json::json; + + use crate::config::TrustedServerAppConfig; + use crate::test_support::tests::crate_test_settings_str; // Which names are valid is only known where the scheme crates are linked, // so the checks that a name matches an available provider, and that none // is repeated, live with the seam in `permission_signal::select`. What is // tested here is the shape of the section itself. + /// The test fixture's configuration with `section` written as its + /// `[permission_signal]` section. + fn settings_toml_with(section: &str) -> String { + format!( + "{}\n[permission_signal]\n{section}\n", + crate_test_settings_str() + ) + } + #[test] fn no_section_is_allowed_and_means_every_provider() { let config = PermissionSignalConfig::default(); assert!( - config.sources.is_none(), + config.provider.is_none(), "absent rather than empty, because the two mean opposite things" ); } @@ -8776,20 +8827,41 @@ mod permission_signal_config_tests { #[test] fn the_section_round_trips_through_toml() { let parsed: PermissionSignalConfig = - toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); + toml::from_str(r#"provider = ["gpc", "tcf"]"#).expect("should parse the section"); assert_eq!( - parsed.sources.as_deref(), + parsed.provider.as_deref(), Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), "the order written is the order read, because the order is the policy" ); } + #[test] + fn the_section_round_trips_through_a_config_blob() { + // The section is written by derive and read by hand, so what a push + // writes into a blob must be what a deployment reads back from it. + let written = PermissionSignalConfig { + provider: Some(vec!["tcf".to_owned(), "gpc".to_owned()]), + }; + let blob = serde_json::to_value(&written).expect("should write the section"); + let read: PermissionSignalConfig = + serde_json::from_value(blob).expect("should read back what was written"); + assert_eq!(read, written, "the list and its order survive the blob"); + + let null: PermissionSignalConfig = serde_json::from_value(json!({ "provider": null })) + .expect("should read an explicit null"); + assert_eq!( + null, + PermissionSignalConfig::default(), + "an explicit null is the same as leaving the key out" + ); + } + #[test] fn an_empty_list_is_kept_apart_from_no_list() { let parsed: PermissionSignalConfig = - toml::from_str("sources = []").expect("should parse an empty list"); + toml::from_str("provider = []").expect("should parse an empty list"); assert_eq!( - parsed.sources.as_deref(), + parsed.provider.as_deref(), Some(&[][..]), "a publisher acting on no signal at all writes an empty list, and it must \ not read back as having written nothing" @@ -8798,7 +8870,81 @@ mod permission_signal_config_tests { #[test] fn an_unknown_key_is_refused() { - toml::from_str::(r#"source = ["gpc"]"#) + let error = toml::from_str::(r#"providers = ["gpc"]"#) .expect_err("should refuse a misspelled key rather than silently ignore it"); + assert!( + error + .to_string() + .contains("unknown field `providers` in [permission_signal], expected `provider`"), + "the refusal names the key it did not recognize and the one it accepts: {error}" + ); + } + + #[test] + fn the_removed_sources_key_is_refused_naming_provider() { + for written in [ + r#"sources = ["gpc", "tcf"]"#, + "provider = [\"gpc\", \"tcf\"]\nsources = [\"gpc\", \"tcf\"]", + ] { + let error = toml::from_str::(written) + .expect_err("should refuse the removed key, alone or beside its replacement"); + assert!( + error.to_string().contains( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead" + ), + "the refusal says which key to write instead: {error}" + ); + } + } + + #[test] + fn every_way_settings_are_read_refuses_the_removed_sources_key() { + let written = settings_toml_with(r#"sources = ["gpc", "tcf"]"#); + + let error = Settings::from_toml(&written).expect_err("should refuse the removed key"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "reading a TOML file names the key that replaced it: {error:?}" + ); + + // `ts config push` parses the file into a TOML value before reading the + // settings from it. + let value: toml::Value = toml::from_str(&written).expect("should parse as TOML"); + let error = value + .try_into::() + .expect_err("should refuse the removed key before a push"); + assert!( + error.to_string().contains("[permission_signal] provider"), + "a push names the key that replaced it: {error}" + ); + + // A deployment reads its settings from a JSON config blob. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + let mut blob = serde_json::to_value(settings).expect("should serialize the fixture"); + blob["permission_signal"] = json!({ "sources": ["gpc", "tcf"] }); + let error = + Settings::from_json_value(blob).expect_err("should refuse the removed key at startup"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "startup names the key that replaced it: {error:?}" + ); + } + + #[test] + fn a_block_of_provider_settings_is_refused_as_an_unknown_field() { + // No provider takes settings yet, so a block for one is refused rather + // than read and then ignored. + let written = + settings_toml_with("provider = [\"gpc\"]\n\n[permission_signal.gpc]\nenabled = true"); + let error = + Settings::from_toml(&written).expect_err("should refuse settings no provider takes"); + let message = format!("{error:?}"); + assert!( + message.contains("unknown field `gpc` in [permission_signal]") + && message.contains("[permission_signal.] block is not accepted"), + "the refusal names the block and says why it is refused: {message}" + ); } } diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 32b0278ee..e674e0a8e 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -354,7 +354,7 @@ a provider runs. A consent record that is present but cannot be decoded blocks baseline grants (fail-closed) rather than degrading to the no-signal baseline, ahead of every signal provider and whichever are configured. The providers are then asked in -the order `[permission_signal] sources` gives, each amending what the ones +the order `[permission_signal] provider` gives, each amending what the ones before it settled, and the last with an opinion decides. So which of a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) and a consenting TCF record stands when they disagree is the configured order, not a rule in code. diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md index bbfc7898c..a31ac16c1 100644 --- a/docs/guide/permission-signals.md +++ b/docs/guide/permission-signals.md @@ -74,7 +74,7 @@ deployment's to set, not the code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] ``` A provider not on the list does not run, and there is no separate switch. A @@ -82,19 +82,25 @@ publisher who does not want to act on Global Privacy Control removes `"gpc"` from the list, and the provider that reads the header then does not run. One caveat: the core's consent pipeline can also synthesize a US Privacy opt-out from that header for a visitor in a US state, when the consent settings say to, -which they do by default, and the `us-privacy` provider then acts on the record +which they do by default, and the `us_privacy` provider then acts on the record it produced. A publisher who wants the header to have no effect at all turns -that setting off as well. Leaving the section out entirely runs every provider -the adapter offers, in the order it offers them, so a signal is never quietly -ignored because someone forgot to list it. An empty list runs none of them, -which is a publisher acting on no signal at all, and leaves every permission -at its country and region baseline. +that setting off as well. Leaving `provider` out, or the section entirely, runs +every provider the adapter offers, in the order it offers them, so a signal is +never quietly ignored because someone forgot to list it. An empty list runs +none of them, which is a publisher acting on no signal at all, and leaves every +permission at its country and region baseline. A name matching no provider the adapter links, or a name given twice, is refused at startup rather than ignored, so a typo cannot silently stop a scheme being honored. What ran, and what was left out, is written to the log once at startup. +Providers are named in `snake_case`, lowercase words joined by underscores. A +provider that gains settings will take them in a `[permission_signal.]` +block named for it. None of the four that ship has settings, so `provider` is +the only key the section accepts, and a block or any other key is refused as +an unknown field rather than ignored. + The default order asks the signal with no interface of its own first and the ones carrying a choice made through an interface after. Global Privacy Control is a browser setting, so it revokes personalization on arrival, @@ -109,8 +115,8 @@ question about a jurisdiction and a publisher. | Identifier | Crate | Reads | | ------------------ | ------------------------------------- | ------------------------------------------------- | | `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | -| `gpp-sale-opt-out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | -| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | +| `gpp_sale_opt_out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | +| `us_privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | | `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | The three opt-outs are separate so that a publisher who does not act on Global @@ -177,7 +183,8 @@ record at all, so it must not degrade to the no-signal baseline. 1. Create a crate that depends on `trusted-server-core` and implements `PermissionSignalProvider` from `trusted_server_core::permission_signal`. - Give it a stable identifier, which is the name configuration uses. + Give it a stable identifier in `snake_case`, which is the name + configuration uses. 2. Answer neutral for a permission the scheme has no opinion on, including when its signal is absent from the request. Reading an absent signal as a refusal would revoke the permission on every request that did not carry diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index b5be589af..1778aa276 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -16,7 +16,7 @@ use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; /// The signal providers the inspector asks, in the order every adapter offers -/// them when `[permission_signal] sources` names none. +/// them when `[permission_signal] provider` names none. fn providers() -> Vec> { vec![ Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 4fef496da..0f158e49c 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -211,24 +211,30 @@ pull_sync_concurrency = 3 # # Signals compose rather than select. A request can carry a TCF string and a # Global Privacy Control header at once, and both have something to say, so -# this is a list, whereas [ec], [geo] and [device] each name one provider. The -# order is the policy, because the last provider with an opinion decides. +# provider here is a list, whereas in [ec], [geo] and [device] it names one +# provider. The order is the policy, because the last provider with an opinion +# decides. # # Listed below is every provider the shipped adapters link, which is also -# exactly what runs when this section is absent. Each is a crate under -# crates/permission-signal, outside the core. Remove the ones this publisher -# does not want to act on. A provider that is not on the list does not run, -# and there is no separate switch to turn one off. An unknown or repeated name -# is refused at startup rather than quietly ignored. +# exactly what runs when provider is left out. Each is a crate under +# crates/permission-signal, outside the core. Providers are named in +# snake_case, lowercase words joined by underscores. Remove the ones this +# publisher does not want to act on. A provider that is not on the list does +# not run, and there is no separate switch to turn one off. An unknown or +# repeated name is refused at startup rather than quietly ignored. # # [permission_signal] -# sources = [ +# provider = [ # "gpc", # the Sec-GPC request header, Global Privacy Control -# "gpp-sale-opt-out", # a GPP US sale opt-out -# "us-privacy", # a US Privacy string sale opt-out +# "gpp_sale_opt_out", # a GPP US sale opt-out +# "us_privacy", # a US Privacy string sale opt-out # "tcf", # TCF v2, with its purpose mapping in the crate # ] # +# A provider that gains settings will take them in a [permission_signal.] +# block named for it. None of these four has settings, so such a block is +# refused rather than ignored. +# # The three opt-outs are separate entries so that a publisher who does not act # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. From 6e61e0b693502bd7ca1ee9b0e388a9d508682894 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 14:33:49 +0100 Subject: [PATCH 101/133] Name the host-signal Edge Cookie provider host_signals Every name an operator types into configuration is snake_case, so the built-in host-signal provider is now selected with `[ec] provider = "host_signals"` and configured with `[ec.providers.host_signals]`. The key constant, the serde name of the typed block, the validation error key, the registered secret path `ec.providers.host_signals.passphrase`, the placeholder-secret report, the two startup messages that spell the key and the provider's own log line all follow. This commit changes only that one name. The `HostSignals` trait, the `host_signals` fields and arguments that carry it, and the prose that calls this the host-signal provider are unchanged, because a host capability is not a configuration value. The test secret-store key name `host-signals-passphrase-key` is unchanged for the same reason, being a name an operator picks for a secret rather than a provider name. The Rust field name and the configuration name are now the same word, so the `#[serde(rename)]` on the typed block goes and the field is declared the same way as `hmac` beside it. That also retires the reason the `EcProviders` validation keys were spelled out by hand, so the note above those keys is rewritten to say what still holds, which is that each key has to match the secret path `TrustedServerAppConfig::secret_fields` registers. The old name is refused when settings load, which every adapter does before it serves a request. The message names `host_signals` and says what to write. The refusal comes before any block is looked for, because a block left behind under the old name is captured as a vendor block, so the old selector would otherwise find that block, pass the settings check, and fail later in provider resolution with a message about an adapter that supplies no such provider. That is a breaking change, accepted for a major release. Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` passes. The core suite passes 2,764 tests and 5 doc-tests with 4 ignored, the new test among them. The Axum adapter passes 43 tests across its three binaries, `cargo clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` builds. --- crates/trusted-server-adapter-axum/src/app.rs | 2 +- crates/trusted-server-core/src/config.rs | 12 +- .../trusted-server-core/src/config_payload.rs | 8 +- crates/trusted-server-core/src/ec/provider.rs | 28 +++- crates/trusted-server-core/src/settings.rs | 136 +++++++++++++++--- 5 files changed, 149 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 5c167b53b..2b4af7ec9 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -95,7 +95,7 @@ fn build_state_with_settings( // for every request. It injects and threads no provider, so `EcContext` // resolves the selection itself on every request, building a fresh built-in // provider that reads no request data. It supplies no host signals either, - // so the host-signals argument is `None`. + // so the `host_signals` argument is `None`. ensure_provider_available(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 3057080c9..b28218e85 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -158,7 +158,7 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { vec![ object("ec"), optional_object("providers"), - optional_object("host-signals"), + optional_object("host_signals"), object("passphrase"), ], true, @@ -416,7 +416,7 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report]` block is absent, when a provider block - /// is configured with no selector or alongside `"none"`, or when a - /// configured block is not the selected one. Any selector name is accepted - /// so long as its block is present, so there is no unknown-key check. + /// Returns [`TrustedServerError::Configuration`] when the selector is the + /// old `host-signals` spelling, when the selected provider's + /// `[ec.providers.]` block is absent, when a provider block is + /// configured with no selector or alongside `"none"`, or when a configured + /// block is not the selected one. Any other selector name is accepted so + /// long as its block is present, so there is no unknown-key check. pub fn validate_provider_selection(&self) -> Result<(), Report> { let Some(selection) = self.provider.as_ref() else { if !self.providers.is_empty() { @@ -713,11 +721,28 @@ impl Ec { return Ok(()); }; + let key = key.as_str(); + + // The old spelling of the host-signal provider's name is refused + // before any other question is asked. A block left behind under the + // old name is captured as a vendor block, so without the refusal the + // old selector would find that block, pass the block check below, and + // fail later in provider resolution with a message about an adapter + // that supplies no such provider. + if key == RETIRED_HOST_SIGNALS_PROVIDER_KEY { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"host-signals\" is no longer accepted. The \ + host-signal provider is now named \"host_signals\", so set [ec] \ + provider = \"host_signals\" and rename its block to \ + [ec.providers.host_signals]" + .to_owned(), + })); + } + // Every provider is configured by the `[ec.providers.]` block that // carries its own name, so the check is the same lookup for all of // them. A provider the adapter injects has the contents of its block // validated by that adapter when it builds the provider. - let key = key.as_str(); if !self.providers.has_block(key) { return Err(Report::new(TrustedServerError::Configuration { message: format!( @@ -817,10 +842,10 @@ pub struct EcProviders { #[serde(default)] pub hmac: Option, - /// The built-in host-signal provider, keyed `host-signals`. Creates the Edge + /// The built-in host-signal provider, keyed `host_signals`. Creates the Edge /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it /// requires a host that supplies those signals. - #[serde(default, rename = "host-signals")] + #[serde(default)] pub host_signals: Option, /// Configuration blocks for vendor or host providers that live in their own @@ -836,13 +861,15 @@ pub struct EcProviders { /// Validates each built-in provider block under the key the configuration /// uses for it. /// -/// The derived implementation would key a nested error by the Rust field name, -/// `host_signals`, while the configuration, the secret-store resolution and the -/// secret paths `TrustedServerAppConfig::secret_fields` registers all use -/// `host-signals`. `edgezero_core::app_config::validate_excluding_secrets` -/// matches those paths against the error keys verbatim, so a derived key would -/// leave the passphrase checked as a value when it holds a key name at push -/// time. Vendor blocks are validated by the adapter that builds the provider. +/// Each key is spelled out here rather than derived because +/// `edgezero_core::app_config::validate_excluding_secrets` matches the secret +/// paths `TrustedServerAppConfig::secret_fields` registers against these error +/// keys verbatim, so the two are read together and a key that drifted from its +/// registered path would leave the passphrase checked as a value when it holds +/// a key name at push time. Every built-in name is `snake_case`, so each key +/// here is also the Rust field name, the configuration name and the name in +/// the registered path. Vendor blocks are validated by the adapter that builds +/// the provider. impl Validate for EcProviders { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); @@ -850,7 +877,7 @@ impl Validate for EcProviders { errors.merge_self("hmac", hmac.validate()); } if let Some(host_signals) = &self.host_signals { - errors.merge_self("host-signals", host_signals.validate()); + errors.merge_self("host_signals", host_signals.validate()); } if errors.errors().is_empty() { Ok(()) @@ -920,7 +947,7 @@ pub struct HmacProviderConfig { /// Configuration for the built-in host-signal Edge Cookie provider. /// -/// Mapped from the `[ec.providers.host-signals]` TOML block. +/// Mapped from the `[ec.providers.host_signals]` TOML block. #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct HostSignalsProviderConfig { @@ -3491,7 +3518,7 @@ impl Settings { if let Some(host_signals) = &self.ec.providers.host_signals && Ec::is_placeholder_passphrase(host_signals.passphrase.expose()) { - insecure_fields.push("ec.providers.host-signals.passphrase".to_owned()); + insecure_fields.push("ec.providers.host_signals.passphrase".to_owned()); } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -5667,6 +5694,77 @@ mod tests { ); } + #[test] + fn the_old_host_signals_spelling_fails_at_startup_and_names_the_new_one() { + // The host-signal provider was renamed to `host_signals` under the + // rule that every name an operator types into configuration is + // `snake_case`. A deployment still configured with the old spelling + // has to stop when settings load, which every adapter does before it + // serves a request, and the error has to name the spelling to write + // instead. + let selecting = |selector: &str, block: &str| { + let toml = crate_test_settings_str() + .replace("[ec.providers.hmac]", block) + .replace("provider = \"hmac\"", &format!("provider = \"{selector}\"")); + assert!( + toml.contains(&format!("provider = \"{selector}\"")) + && toml.contains(block) + && !toml.contains("[ec.providers.hmac]"), + "the test configuration should select `{selector}` with only `{block}` configured" + ); + toml + }; + + // A block left under the old name is captured as a vendor block, so + // the old selector would find it and pass this check if the name were + // not refused before the block is looked for. + let old = selecting( + RETIRED_HOST_SIGNALS_PROVIDER_KEY, + "[ec.providers.host-signals]", + ); + let err = + Settings::from_toml(&old).expect_err("the old spelling should fail when settings load"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "the old spelling should be a configuration error, got: {:?}", + err.current_context() + ); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should name `host_signals`, got: {err}" + ); + + // With no block at all the answer has to be the same one, naming the + // new spelling rather than asking for a block under the old name. + let old_without_block = crate_test_settings_str() + .replace("[ec.providers.hmac]", "") + .replace("passphrase = \"test-secret-key-32-bytes-minimum\"", "") + .replace( + "provider = \"hmac\"", + &format!("provider = \"{RETIRED_HOST_SIGNALS_PROVIDER_KEY}\""), + ); + let err = Settings::from_toml(&old_without_block) + .expect_err("the old spelling should fail with no block either"); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should still name `host_signals`, got: {err}" + ); + + // The same configuration written with the new spelling loads. + let settings = Settings::from_toml(&selecting( + HOST_SIGNALS_PROVIDER_KEY, + "[ec.providers.host_signals]", + )) + .expect("the `host_signals` spelling should load"); + assert!( + settings.ec.providers.host_signals.is_some(), + "the renamed block should deserialize into the typed field" + ); + } + #[test] fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { let config = GeoConfig::default(); From 14a3ffb0c85d1d1a1c290c149a73db5d83b92bbd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 14:35:44 +0100 Subject: [PATCH 102/133] Give each identity provider its own table under [ec] The Edge Cookie provider blocks move from [ec.providers.] to [ec.], so identity follows the one convention every provider type uses, where [] provider = "" selects and [.] holds that provider's settings. The [ec.providers] table is gone, and a configuration still carrying it is rejected with the new location in the message. A block exists only when the provider has settings. The built-in hmac provider has a required passphrase, so selecting it still needs [ec.hmac], while a provider with no settings needs no block at all. Only the adapter that injects a provider knows whether that provider has settings, so core no longer demands a block for a name it does not supply itself. A block may name the implementation it configures with implementation = "", which makes the block's own name a label of the operator's choosing. [ec] provider = "primary" with [ec.primary] holding implementation = "hmac" and a passphrase configures the built-in provider under a name that means something to the deployment. Everything that resolves the selection now reads the implementation rather than the label, covering the built-in lookup, the matching of a provider the adapter injects, the check that a selected implementation has the settings it needs, and the errors. An implementation this deployment cannot build fails startup naming the implementations it does have. The fixed [ec] keys stay reserved and cannot name a provider, every other key in the section has to be a table, and a key that is not one is reported as the unknown field it almost certainly is, so a typo such as ec_stor is still caught with a sensible message. Provider names and implementation ids are snake_case. A block the selector does not name still fails startup, as it did before. Secrets follow the blocks. TrustedServerAppConfig::secret_fields now lists ec.hmac.passphrase, and EdgeZero's path segments cannot say "whatever name the operator chose", so core reads the labeled blocks out of the configuration itself through the new ConfiguredSecretFields trait and resolves their passphrases from trusted_server_secrets in the same pass. Push-time validation, where those fields hold key names rather than secrets, no longer runs the passphrase value check against a labeled block's key name. The check itself is unchanged wherever settings are loaded with their secrets resolved. The legacy [ec] passphrase shim still works and now points at [ec.hmac]. --- crates/edgecookie/README.md | 10 +- crates/trusted-server-adapter-axum/src/app.rs | 6 +- .../src/middleware.rs | 2 +- .../tests/routes.rs | 17 +- .../src/app.rs | 6 +- .../src/middleware.rs | 2 +- .../tests/routes.rs | 19 +- .../trusted-server-adapter-fastly/src/app.rs | 10 +- .../trusted-server-adapter-fastly/src/main.rs | 2 +- .../src/middleware.rs | 2 +- crates/trusted-server-adapter-spin/src/app.rs | 6 +- .../src/middleware.rs | 2 +- .../tests/routes.rs | 17 +- crates/trusted-server-core/src/config.rs | 199 +++- .../trusted-server-core/src/config_payload.rs | 72 +- crates/trusted-server-core/src/ec/mod.rs | 12 +- crates/trusted-server-core/src/ec/provider.rs | 272 ++++-- crates/trusted-server-core/src/edge_cookie.rs | 11 +- .../src/integrations/google_tag_manager.rs | 4 +- .../src/integrations/prebid.rs | 2 +- .../src/integrations/testlight.rs | 2 +- crates/trusted-server-core/src/proxy.rs | 2 +- .../src/response_privacy.rs | 2 +- .../src/secret_resolution.rs | 87 +- crates/trusted-server-core/src/settings.rs | 881 +++++++++++++----- .../trusted-server-core/src/test_support.rs | 59 +- .../configs/trusted-server.integration.toml | 2 +- .../tests/parity.rs | 2 +- trusted-server.example.toml | 28 +- 29 files changed, 1308 insertions(+), 430 deletions(-) diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md index 6d15fd719..0c8d6be59 100644 --- a/crates/edgecookie/README.md +++ b/crates/edgecookie/README.md @@ -6,5 +6,13 @@ from `trusted-server-core` and is wired in by an adapter. The built-in HMAC provider (HMAC over the client IP) ships in `trusted-server-core` (`ec::provider`), so no crate is needed for it. There is -no default provider; a deployment selects one explicitly with `[ec] provider`. +no default provider, and a deployment selects one explicitly with +`[ec] provider`. + +A provider's own settings live in the `[ec.]` table the selector names. +The name is the provider's implementation id, the same string its +`EdgeCookieProvider::id` returns, unless the table names one with +`implementation = ""`, which lets an operator configure a provider under a +name of their own choosing. A provider with no settings needs no table. + This directory is a placeholder until a vendor provider is added. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 01c9c4949..9ddce9601 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -683,8 +683,8 @@ mod tests { use super::*; /// Settings selecting a vendor Edge Cookie provider this adapter does not - /// inject, with the `[ec.providers.]` block configuration validation - /// requires. `acme` is a fictional vendor key. + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -700,7 +700,7 @@ mod tests { [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 6a78e042d..609d66a25 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -195,7 +195,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 03ba6aeff..16ac3d54a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -35,7 +35,7 @@ fn test_settings() -> trusted_server_core::settings::Settings { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -876,8 +876,8 @@ async fn first_party_proxy_rebuild_is_routed() { // --------------------------------------------------------------------------- /// Test settings selecting a vendor Edge Cookie provider this adapter does not -/// inject, with the `[ec.providers.]` block configuration validation -/// requires. `acme` is a fictional vendor key. +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -893,17 +893,18 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; /// A provider selection this adapter can never supply must fail while the /// application state is built, before any request is served. /// -/// Configuration validation accepts this pair (the `[ec.providers.acme]` block -/// is present), and the Axum dev server injects no vendor Edge Cookie provider, -/// so only the composition root can catch it. Without the startup check the -/// deployment would come up and answer every request. +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and the Axum dev +/// server injects no vendor Edge Cookie provider, so only the composition root +/// can catch it. Without the startup check the deployment would come up and +/// answer every request. #[test] fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 09a31ca1f..cb7bd4f37 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -703,8 +703,8 @@ mod tests { use super::*; /// Settings selecting a vendor Edge Cookie provider this adapter does not - /// inject, with the `[ec.providers.]` block configuration validation - /// requires. `acme` is a fictional vendor key. + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -720,7 +720,7 @@ mod tests { [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 8c1aa2894..a9c5ba3d9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -211,7 +211,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index b300e5b37..5232b6677 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -38,7 +38,7 @@ fn test_router() -> RouterService { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -90,7 +90,7 @@ fn make_router() -> RouterService { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -687,8 +687,8 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { // --------------------------------------------------------------------------- /// Test settings selecting a vendor Edge Cookie provider this adapter does not -/// inject, with the `[ec.providers.]` block configuration validation -/// requires. `acme` is a fictional vendor key. +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -704,17 +704,18 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; /// A provider selection this adapter can never supply must fail while the /// application state is built, before any request is served. /// -/// Configuration validation accepts this pair (the `[ec.providers.acme]` block -/// is present), and this adapter injects no vendor Edge Cookie provider, so only -/// the composition root can catch it. Without the startup check the deployment -/// would come up and answer every request. +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. #[test] fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 88c019715..28534e50f 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1552,7 +1552,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1626,7 +1626,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2102,7 +2102,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2758,7 +2758,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -3182,7 +3182,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 27151f076..de661f3c8 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -531,7 +531,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 39c86ffdd..060345787 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -322,7 +322,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 26318cc35..dfbfef076 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1099,8 +1099,8 @@ mod tests { } /// Settings selecting a vendor Edge Cookie provider this adapter does not - /// inject, with the `[ec.providers.]` block configuration validation - /// requires. `acme` is a fictional vendor key. + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -1116,7 +1116,7 @@ mod tests { [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index a9698350c..864d4961d 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -238,7 +238,7 @@ mod tests { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 4b315ee14..e4ca7f03e 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -37,7 +37,7 @@ fn test_router() -> RouterService { [ec] provider = "hmac" - [ec.providers.hmac] + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -982,8 +982,8 @@ async fn admin_deactivate_key_auth_fail_returns_401() { // --------------------------------------------------------------------------- /// Test settings selecting a vendor Edge Cookie provider this adapter does not -/// inject, with the `[ec.providers.]` block configuration validation -/// requires. `acme` is a fictional vendor key. +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. const UNINJECTED_PROVIDER_TOML: &str = r#" [[handlers]] path = "^/_ts/admin" @@ -999,17 +999,18 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec] provider = "acme" - [ec.providers.acme] + [ec.acme] endpoint = "https://ec.acme.example.com" "#; /// A provider selection this adapter can never supply must fail while the /// application state is built, before any request is served. /// -/// Configuration validation accepts this pair (the `[ec.providers.acme]` block -/// is present), and this adapter injects no vendor Edge Cookie provider, so only -/// the composition root can catch it. Without the startup check the deployment -/// would come up and answer every request. +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. #[test] fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index b40a9b785..9fb505616 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,8 +11,9 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::provider::HMAC_PROVIDER_KEY; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -32,7 +33,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +use crate::settings::{ + AssetOriginAuth, Ec, IntegrationConfig, PROVIDER_IMPLEMENTATION_KEY, Settings, +}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -117,6 +120,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_provider_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +135,90 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie provider blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_provider_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + for (name, _) in ec + .provider_blocks + .hmac_blocks() + .filter(|(name, _)| *name != HMAC_PROVIDER_KEY) + { + let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) + else { + continue; + }; + block_errors.errors_mut().remove("passphrase"); + if block_errors.errors().is_empty() { + ec_errors.errors_mut().remove(name); + } + } + // An `ec` entry holding nothing would keep the whole result an error, the + // same reason `EdgeZero` prunes emptied containers after its own removals. + let ec_is_empty = ec_errors.errors().is_empty(); + if ec_is_empty { + errors.errors_mut().remove("ec"); + } +} + +impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { + /// The passphrase of every Edge Cookie provider block that configures the + /// built-in HMAC provider under a label. + /// + /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) + /// lists the passphrase of the `[ec.hmac]` block, the one path this + /// provider's block has when its name is its implementation. The same + /// provider under a label of the operator's choosing holds that secret at + /// `ec.