From 452915113601dcd19b11f511fa5cfa81eef7e65c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 18 Aug 2026 19:38:03 +0100 Subject: [PATCH 1/3] 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 + .../2026-07-30-pluggable-providers-design.md | 585 +++++++++++++ .../specs/provider-code-registry.md | 30 + trusted-server.example.toml | 11 +- 34 files changed, 3120 insertions(+), 272 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 create mode 100644 docs/superpowers/specs/2026-07-30-pluggable-providers-design.md create mode 100644 docs/superpowers/specs/provider-code-registry.md 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 45cbedc2c..0009e1953 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -135,6 +135,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 5b605bcff..cb22e2126 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -151,6 +151,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 41e5e65ee..f9a44b33d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -822,7 +822,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 @@ -1353,6 +1353,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1422,6 +1425,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1854,6 +1860,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2493,6 +2502,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2618,6 +2630,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 a19d0485d..ca5607f9b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -522,6 +522,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 18f309c68..7702386df 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -300,6 +300,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 1bcede1fc..3cadf721d 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -178,6 +178,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 818b6fcc5..75f22b864 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -365,6 +365,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 0dfb5906f..8eaa82a75 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1537,6 +1537,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] @@ -1570,6 +1573,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 c9b3f5ded..06b987d6e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3024,6 +3024,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 ddc8ac612..fde31f749 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -423,9 +423,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)] @@ -513,6 +542,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)] @@ -2720,6 +2934,8 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_passphrase()?; + settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2810,8 +3026,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()); @@ -3629,9 +3847,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"); } @@ -3798,6 +4021,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!( @@ -3904,6 +4155,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!( @@ -4017,6 +4287,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!( @@ -4094,6 +4393,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( @@ -4415,7 +4782,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()); @@ -5034,6 +5403,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" "#, ) @@ -5065,6 +5437,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" "#, ); @@ -6219,6 +6594,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] @@ -6552,6 +6930,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] @@ -6636,6 +7017,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] @@ -6672,6 +7056,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] @@ -6714,6 +7101,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/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md new file mode 100644 index 000000000..5075c54bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md @@ -0,0 +1,585 @@ +# Design Spec: Pluggable Edge Cookie, Device, and Geo Providers + +**Status:** Implemented in PR #1043 (Edge Cookie provider seam) and PR #1044 +(device and geo selection); revised against the implementation, 2026-08-25. +**Author:** Engineering +**Issue references:** #777, #778, #780, #781 +**Related specs:** `2026-07-30-permission-model-design.md`, +`2026-07-30-provider-migration-rollout-design.md`, +`2026-07-30-client-cycle-ec-resolve-design.md` +**Last updated:** 2026-08-25 + +> **Context.** PR #838 proposed a first implementation of this epic in a single +> change. Review of that PR surfaced design gaps this spec exists to close +> before a second implementation pass: an identity abstraction that owned +> minting but not recognition, per-adapter divergence in provider selection, +> silent misconfiguration modes, and speculative trait surface with no +> production caller. This spec is the authoritative statement of what the +> provider architecture must do; where it contradicts PR #838, this spec wins. +> The second pass has now landed (PR #1043 and PR #1044, with permission +> enforcement in PR #1045), and this revision restates the spec to match the +> implemented code. A final section records every divergence from the +> 2026-07-31 draft. + +--- + +## 1. Overview and goals + +Trusted Server makes three per-request data decisions that were previously +hard-wired: whether to create or keep an Edge Cookie (EC) identity, how to +classify the requesting device, and whether to resolve geolocation. Each is +now a **provider**, a selectable component chosen in operator configuration, +with a deliberately neutral default. + +Goals, as implemented: + +- A deployment picks an implementation per concern (including none) without a + code change to Trusted Server core. +- Defaults are neutral. With no configuration, no EC is created, device + classification uses only the User-Agent, and no geolocation is performed. A + default deployment makes no third-party or host-specific call. +- An **EC provider declares** the permissions its data use requires + (`required_permissions` on the trait), and **core enforces** that + declaration before minting or using an identity. A provider cannot + authorize itself. The enforcement machinery is the permission model's + subject and lands with it in PR #1045 (see the permission model spec). + Geo and device carry the same declaration method with an empty default, + for the reasons spelled out in section 5. +- All adapters (Fastly, Axum, Cloudflare, Spin) route selection through the + same core builders, so identical configuration selects identical providers + everywhere. A selection the deployment cannot satisfy fails loudly rather + than degrading. The EC API routes (identify, batch-sync, ec/resolve) are + registered by the Fastly entry point only today, because the portability + adapters do not yet wire a platform KV store. The Spin adapter's route + list documents that gap explicitly rather than leaving those paths silent. + +Non-goals: + +- No vendor provider ships in this epic beyond the host-platform + implementations named below. The `crates/edgecookie/` directory holds a + README describing where vendor EC crates will live. +- The client-cycle (browser round-trip) provider type has its own spec. The + trait ships the seam for it (`resolve_from_client`, a no-op by default) + and a demonstration provider (`client-fixed`) compiled only into test and + demonstration builds. Production selection of the demo provider is a + startup error. + +## 2. Provider taxonomy + +| Concern | Trait | Built-in default | Opt-in implementations | +| ----------- | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| EC identity | `EdgeCookieProvider` | none (stateless) | `hmac` (in core, HMAC over client IP, preserves today's identity), `host-signals` (in core, see below), and `client-fixed` (demo builds only) | +| Device | `DeviceProvider` | `builtin` (User-Agent only) | `fastly` (TLS JA4 and HTTP/2 signals through an injected `HostSignals` service) | +| Geo | `PlatformGeo` | none (no location) | `platform` (host geo lookup) | + +The geo trait is the existing `PlatformGeo` in `platform/traits.rs` rather +than a new `GeoProvider` name. The EC trait lives in `ec/provider.rs` and the +device trait in `ec/device.rs`. + +Selection keys are strings in operator configuration +(`trusted-server.example.toml` carries the commented template): + +```toml +[ec] +provider = "hmac" + +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + +[device] +provider = "builtin" # default. "fastly" opts into TLS/H2 signal evidence + +[geo] +provider = "platform" # default is none (no location, no host call) +default_country = "FR" # required (section 6) +# assume_single_jurisdiction = true # required when EC runs with no geo +``` + +**The `host-signals` EC provider** (identity from HMAC over the host TLS JA4 +and HTTP/2 fingerprints plus the client IP) was deliberately dropped from the +2026-07-31 draft. It has since shipped in PR #1044 as an opt-in built-in +(`[ec.providers.host-signals]`), implemented against the host-agnostic +`HostSignals` capability rather than a Fastly API, so any host that supplies +the fingerprints can run it and a host that supplies none cannot build it. +When the host supplies no fingerprint at all the provider defers with a +warning instead of degrading to an IP-only identifier under the host-signals +name. **An open review question stands on whether this provider should ship +in the series at all**, because its identifier shape shares the built-in +HMAC grammar and a sign-off row defers host fingerprint processing. The +question is flagged for the series review and this spec does not present +the provider as settled either way. + +## 3. The identity lifecycle contract + +This is the section PR #838 lacked. Its trait abstracted **minting** an +identifier but left **recognition** and **KV key normalization** hard-coded +to the built-in HMAC shape, so a provider whose identifiers did not match +`{64hex}.{6alnum}` minted cookies that the very next request discarded. + +The implemented contract routes every lifecycle operation core performs on +an EC value through the selected provider: + +| Lifecycle operation | Where core uses it | Contract | +| ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Mint** | EC generation on first eligible request, and the client-cycle resolve endpoint | The provider returns the identifier (`generate` server-side, `resolve_from_client` for the client cycle) and only core writes the cookie, after enforcing the global bounds below. | +| **Recognize** | Reading `ts-ec` back from the request, deciding `ec_was_present`, withdrawal checks | `accepts_id` answers whether a value is a well-formed identifier the provider issues. A value the selected provider does not recognize is treated as absent, so it is never used or egressed, while the raw cookie value stays visible to withdrawal handling. | +| **KV key** | Identity-graph row reads and writes | `normalize_id_for_kv` returns the key form. The default lowercases the built-in HMAC hash segment and preserves the suffix, keeping today's keys. An opaque or case-sensitive provider overrides to the identity function so distinct identifiers never collapse into one row. | +| **Withdraw** | Expiring the cookie and writing revocation markers | The identifiers eligible for withdrawal are exactly those the selected provider accepts through `accepts_id`, never a shape check the provider cannot influence. | + +**Invariant:** for every provider `P` and every identifier `id` minted by +`P`, `id` round-trips read-back byte for byte. A test in `ec/mod.rs` proves +the round-trip with a non-default provider whose identifiers are opaque, and +a second test in `ec/resolve.rs` proves the client-cycle value survives the +full scenario verbatim. + +The draft's richer lifecycle surface, a canonicalizing `parse` with +per-provider equivalence fixtures, a core-constructed graph key built from a +provider `graph_key_suffix`, a declared cluster-prefix capability, declared +namespace descriptors with a startup disjointness proof, and a reusable +conformance suite driven by fixtures, is **not implemented in these PRs**. +Recognition plus KV normalization proved sufficient for the operations core +actually performs today, and each deferred piece is tracked as follow-up +work rather than silently dropped (see the revision record). Until the key +grammar lands, the KV key is the provider's normalized identifier verbatim, +which keeps every pre-epic HMAC row reachable, and the pre-epic IP-cluster +prefix listing (keyed on the 64-hex HMAC hash prefix) continues unchanged. + +One global rule sits above every provider, and it is implemented: + +- **Identifier bounds.** A minted identifier obeys a global cookie-safe + alphabet (normatively `[A-Za-z0-9._~-]`, valid cookie octets with no + separators, whitespace, or control characters) and a global maximum of + **256 bytes**, stated here so dependent documents reference one number. + The bound applies to the identifier itself, not only its key form. Core + enforces the bound wherever an identifier enters the system, at mint + (both `generate` and the resolve endpoint), at cookie read-back, and at + cookie write. The constant is `MAX_EC_ID_LEN` in `ec/cookies.rs`. A + violating value is rejected outright and logged. No sanitizing rewrite + exists anywhere on the path, so an identifier survives byte for byte or + not at all, and the cookie value and the identity-graph key can never + silently diverge. + +## 4. Trait surface: minimalism rule + +Every trait method must have at least one production (non-test) caller in +the same change that introduces it. How the surface observed in PR #838 +resolved in the implementation: + +- `keys_equal`: **not shipped.** Its legitimate purpose (equivalent-envelope + comparison, #778) is served structurally, because read-back acceptance and + KV normalization both route through the provider, so no comparison method + exists to leave uncalled. +- `GeneratedEdgeCookie::response_headers`: **shipped, with a production + caller.** EC finalization applies provider-requested headers to the + outbound response, and the client-cycle resolve path returns them, which + is how a client-side provider requests further evidence from the page. + The draft banned the field when nothing consumed it. The consumer landed + in the same series, satisfying the rule the ban enforced. +- `IdentityInput.permissions` / `IdentityInput.consent`: **shipped and + populated.** The organic mint path passes the request's resolved + permission state and consent context so a provider can read them for + behavior beyond gating. The gate itself has already run before `generate` + is called, so a provider cannot use the fields to authorize itself. +- `required_permissions` on `DeviceProvider` and `PlatformGeo`: **present, + with an empty default and no enforcement point.** The draft removed the + method from both traits because PR #838's copies were decorative. The + implementation keeps one uniform declaration seam across all three traits + instead. The built-in device and geo providers declare empty sets, and + core enforces the declaration only for the EC provider (section 5), so + nothing reads as a gate that is not one. The geo circularity argument + stands unchanged and is restated in section 5. + +The implemented `EdgeCookieProvider` surface (`ec/provider.rs`): + +```rust +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Stable configuration key ("hmac"). + fn id(&self) -> &'static str; + /// Registered four-character code (provider-code-registry.md), the + /// `{code}~` namespace of every identifier the provider mints. + /// Mandatory, no default: a provider cannot exist without a unique + /// code, so identifiers from different providers can never collide. + fn code(&self) -> ProviderCode; + /// Derives an identifier from the provider's injected services and the + /// request evidence passed at call time. A client-side provider defers + /// here (returns no id) and mints later in resolve_from_client. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + /// Whether `value` is a well-formed identifier this provider issues. + /// Default: the built-in HMAC shape (`<64 hex>.<6 alphanumeric>`). + fn accepts_id(&self, value: &str) -> bool { /* built-in shape */ } + /// The KV-key form of `value`. Default: lowercase the HMAC hash + /// segment, preserve the suffix. Opaque providers return the value + /// unchanged. + fn normalize_id_for_kv(&self, value: &str) -> String { /* ... */ } + /// Permissions this provider's data use requires. Default: none, so a + /// vendor-neutral provider requires no permission. + fn required_permissions(&self) -> PermissionSet { /* none */ } + /// Client-cycle counterpart to generate: mints from a value the page + /// posted to the resolve endpoint, after verifying it. Default: no-op, + /// so a server-side provider does not participate. See the + /// client-cycle spec. + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { /* ... */ } +} +``` + +Core owns the code envelope. At mint it prefixes the provider's value with +`{code}~`, at read-back it strips and checks the code before the provider's +`accepts_id` sees the value part, and the identity graph key preserves the +code verbatim around the provider's canonical form. A cookie carrying +another provider's code is treated as absent, never adopted, so switching +providers cannot silently mix identity populations, and a withdrawal always +acts on a key that can only belong to one provider. The built-in HMAC +provider mints `hmac~<64 hex>.<6 alphanumeric>` and dual-reads its +pre-envelope bare form for one release cycle so deployed cookies keep +working; the bare form belongs to hmac alone. Codes are allocated +append-only in `provider-code-registry.md`, and a leading digit is valid +(`51dd`). + +The draft's alternative shape (`parse` returning a typed `EcId`, +`graph_key_suffix`, `cluster_prefix`, `verify`, and a version-carrying +`GeneratedIdentity`) was not adopted. `verify`, provider versions, and +`mint_version` are tracked follow-up work with the migration spec. +Request data reaches a provider through injected services and the +`RequestInfo` passed at call time, not through a fixed parameter struct, so +a provider can read headers, cookies, client hints, and the URL without a +trait change. + +## 5. Permission enforcement is core's job, for EC providers + +Before minting through an EC provider, core resolves the request's +permission state and refuses when the provider's `required_permissions()` +are not all set. The gate is implemented in `EcContext`. The selected +provider is built once at request read time, its declaration is checked +against the resolved state, and generation is skipped (with a log line +naming the jurisdiction) when the requirement is not met. With no provider +selected, nothing may mint or use an identifier, so the gate is closed +rather than open by default. The enforcement point lands with the +permission model in PR #1045, and the permission model spec governs the +resolution machinery (country and region baselines, signals, and the +requires-signal floor). + +**Recognition and withdrawal always run**, permissions or not. Read-back +acceptance and withdrawal eligibility go through `accepts_id` with no +permission check, and withdrawal handling keeps the raw cookie value even +when the identifier is treated as absent, so an opt-out can always reach +the identity it revokes. A blanket execution gate would refuse to run the +provider in exactly the state an opt-out produces. + +The draft additionally specified an identity activation protocol (a +two-record commit point before any egress), rowless-cookie classification +and per-prefix withdrawal records, negative-record admission rules, and a +typed egress boundary (`AuthorizedIdentity`, +`RedactedRequestView`). **None of that is implemented in these PRs.** +Those positions remain recorded in the draft and are tracked as follow-up +work with the permission model spec, which owns identity-state persistence +and egress typing. The revision record lists them as deferred. + +The gate applies to EC providers **only**. Geo and device are ungated for +two different reasons, stated separately because only one of them is +structural: + +- **Geo: circularity.** The permission set is resolved from jurisdiction, + which is resolved by the geo provider. Gating geo on the resolved set is + unsatisfiable. `PlatformGeo::required_permissions` exists with an empty + default for interface uniformity, and nothing consults it on the lookup + path. +- **Device: host evidence is an explicit opt-in, not authorized by + selection defaults.** Device classification is not an input to permission + resolution. The neutral `builtin` classifier reads only the User-Agent + and makes no host call. The draft went further and made selecting a + fingerprint-reading device provider a startup error pending a separate + security design. The implementation instead ships `[device] provider = +"fastly"` as a selectable opt-in. The Fastly adapter injects a + `HostSignals` service carrying the TLS JA4 and HTTP/2 fingerprints, and + the provider uses them to strengthen the browser/bot gate that guards EC + writes. Identity rows persist the derived classification fields (the JA4 + class segment and a 12-hex-character hash prefix of the HTTP/2 SETTINGS + fingerprint), not raw fingerprints, and the neutral default persists + neither because the builtin provider produces no such fields. + +## 6. Selection, validation, and failure modes + +All configuration validation happens at **settings construction** +(`Settings::finalize_deserialized` runs every check below), so a +misconfiguration expressible in configuration alone is a startup error, +never a silent behavior change. A selection that only the running host can +satisfy (an injected vendor provider, or host fingerprints) fails loudly +when the provider is built, stopping the request rather than degrading. + +| Configuration state | Behavior | +| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[ec] provider` set, its `[ec.providers.]` block missing | Startup error naming the missing block. There is no closed key list in core for EC, because a vendor key is legitimate when its block is present, so an unknown key with no block fails this same check. | +| `[ec.providers.]` block present, `provider` unset | **Startup error.** (In PR #838 this silently ran stateless. The half-migrated config becomes a production identity outage detected by revenue drop. Rejecting it is the fix.) An operator who genuinely wants stateless deletes the block. | +| `provider = "none"` (explicit stateless) | Valid, and means exactly what omitting the selector means. Any configured provider block alongside it is a startup error, the same stray-block rule as below. | +| A configured `[ec.providers.]` block that is not the selected one | **Startup error** (checked for the `hmac` block and every vendor block). An unreferenced block is almost always a mistyped selector or a stale block, and accepting it silently invites configuration drift. | +| A selected vendor key whose provider the adapter did not inject | Loud failure when the provider is built, naming the key, so the deployment never silently runs stateless. | +| `provider = "host-signals"` on a host that supplies no fingerprints | Loud failure when the provider is built. A host that cannot produce `HostSignals` cannot run the provider. | +| `provider = "client-fixed"` in a production build | Startup error. The demonstration provider is compiled only behind the `client-fixed-demo` cargo feature. | +| No `provider`, no providers block | Valid, the neutral default for that concern. | +| Deprecated `[ec] passphrase` | Migrated to `provider = "hmac"` with the passphrase in `[ec.providers.hmac]`, with a deprecation warning naming the new location. Both forms together are rejected so a half-edited file fails loudly instead of one form silently winning. | +| Any unknown key in `[ec]`, `[device]`, `[geo]`, or a built-in provider block | Startup error. `deny_unknown_fields` is on `Ec`, `DeviceConfig`, `GeoConfig`, and both built-in provider config structs, so a typo like `providr`, or a key from a deferred feature (`legacy_providers`, `rewrite_legacy`, `versions`), fails loudly. | +| `[device] provider` names an unknown key | Startup error. Valid keys are `builtin` (default) and `fastly`. | +| `[geo] provider` names an unknown key | Startup error. Valid states are unset (default, no geolocation), `none` (the same, spelled out), and `platform`. | +| `[geo] default_country` unset, or matching no `permissions.yaml` rule | **Startup error.** The value is the permission baseline for a request the geo provider leaves unmatched, so there must always be one and the value must resolve to a real rule. | +| An EC provider configured, no geo provider, `assume_single_jurisdiction` unset | **Startup error.** With geolocation off, every request resolves as `default_country`, so a visitor from any other jurisdiction silently receives the default jurisdiction's rules. That is acceptable only as an explicit operator decision. | + +One draft row was not adopted, the startup error for a minting provider +with no identity-graph store. `[ec] ec_store` remains optional, because the +portability adapters run without platform KV. The client-cycle resolve +endpoint refuses to mint when no graph is available (a cookie without a row +could never be withdrawn through the graph), and the organic path persists +the row whenever the graph is configured. Whether configuration should +force the pairing is follow-up work with the migration spec. + +Vendor provider blocks deserve their own note. Any `[ec.providers.]` +block whose key is not a built-in is captured in core as raw values (a +flattened map), and the adapter that injects the vendor provider +deserializes its own block into the vendor crate's config type. Core never +names a vendor, so a new provider adds nothing to core. The vendor crate +applies its own `deny_unknown_fields` when it deserializes. + +### 6.1 Provider switching: active writer, legacy readers + +Switching `[ec] provider` must not strand the identities the previous +provider minted, and above all must not make a later opt-out unable to +revoke them. The draft specified an ordered `legacy_providers` reader list, +provider `versions` with `mint_version` rotation, provenance tagging, and +retirement evidence rules. **None of that is implemented in these PRs.** +The keys are rejected as unknown, and the design is tracked follow-up work +with the migration spec. + +What the implementation provides today is shape-based continuity. +Read-back and withdrawal go +through the selected provider's `accepts_id`, so after a switch the old +cookies remain recognized exactly when the newly selected provider accepts +their shape (a provider inheriting the default accepts the built-in HMAC +grammar). Old cookies the new provider does not recognize are treated as +absent and never egress. The `cluster_fallback` degradation policy from the +draft is likewise deferred with the cluster capability itself. + +### 6.2 Runtime failure modes + +Startup validation covers configuration. This covers a healthy +configuration meeting an unhealthy runtime. Implemented behavior, each row +logged, none silent: + +| Failure | Behavior | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate` returns an error | No identity this request. The organic caller logs at error level and the request proceeds stateless. No cookie is written. | +| A provider mints an identifier outside the global bounds | Rejected at mint, never rewritten. The organic path yields no identity. The resolve endpoint returns 400. | +| Identity-graph write fails at mint | The mint is undone (no identifier, no cookie), with the error logged. The resolve endpoint returns 503. The next eligible request retries. | +| The host-signals provider finds no TLS/HTTP-2 fingerprints | Defers with a warning. No identity this request, and no degraded IP-only identifier is minted under the host-signals name. | +| Geo lookup **fails** (the provider errors) | Every permission resolves to the requires-signal floor, and the failure is logged at error level. The failure is **not** papered over with the `default_country` baseline. | +| Geo resolves **no location**, or a country/region with no rule | The `[geo] default_country` baseline applies. This is the configured-default case, deliberately distinct from the failure row above (`GeoStatus` in `ec/consent.rs`). | +| An incoming cookie value fails the bounds at read-back | Treated as absent, with a warning naming the source. | + +The distinction between a failed lookup and no location is resolved in +core, where `EcContext::read_from_request_resolving_geo` runs the +configured geo provider itself and classifies the outcome, so every +adapter reports the two states identically. The draft's remaining matrix rows (rowless +withdrawal records, promotion, the negative-intent outbox, the identity +safety breaker, cluster-listing degradation) belong to the deferred +material of sections 5 and 6.3. + +### 6.3 Storage contract + +The draft specified a delimiter-free physical key grammar with fixed-width +segments, a provider-code registry, record classes for family revocation, +authority state, negative-intent outbox, rowless withdrawal, and deployment +metadata, wire schemas with known-answer vectors, and a per-field graph-row +contract. The provider-code registry is now implemented: codes are +allocated in `provider-code-registry.md`, carried as the `{code}~` prefix +of every minted identifier, and therefore present in every graph key. The +key grammar differs from the draft in one deliberate way, a tilde separator +instead of delimiter-free fixed width, because pre-envelope bare +identifiers remain deployed and a code such as `51dd` is valid hex, so +delimiter-free parsing could misread a legacy identifier during the +migration window. The remainder (record classes, family revocation, +authority state, outbox, rowless withdrawal, wire schemas, per-field +contract) is not implemented in these PRs and stands as recorded design for +the follow-ups. + +The implemented storage today keys the identity graph by the selected +provider's `normalize_id_for_kv` output verbatim. For the built-in HMAC +provider that is the identifier with the hash segment lowercased, which is +today's key, so every pre-epic row stays reachable and the pre-epic +cluster prefix listing stays intact. For an opaque provider the identifier +itself is the key. Rows carry the same JSON envelope as before the epic, +extended with the derived device-classification fields noted in section 5. + +## 7. Composition root and adapter parity + +Provider construction happens in one place per concern, in core, called by +every adapter. No adapter wires a concrete implementation directly into the +request path: + +- `build_provider` (`ec/provider.rs`) constructs the selected EC provider, + injecting the host's `HostSignals` when supplied and matching an + adapter-injected vendor provider by its `id()`. The provider is built + once per request during `EcContext` construction and reused for + read-back, the permission gate, and minting, so the per-request + triple-build observed in PR #838 (cloning the secret into a fresh box up + to three times per request) is gone. +- `build_device_provider` (`ec/device.rs`) returns the builtin classifier + unless `fastly` is selected, in which case the adapter's closure builds + the host-evidence provider. +- `build_geo_provider` (`platform/mod.rs`) returns `DisabledGeo` unless + `platform` is selected, in which case the adapter's host geo + implementation is used. All four adapters (Fastly, Axum, Cloudflare, + Spin) route their host geo through this selector when they assemble + their runtime services, verified in each adapter's platform wiring. + +All four adapters construct the EC request state through the same core +constructors (`EcContext::read_from_request_resolving_geo` and its +variants), so selector behavior and the geo failure classification are +identical everywhere. The cross-adapter parity suite +(`trusted-server-integration-tests`) asserts geo response parity across +adapters. The EC API routes are Fastly-only today, as section 1 notes, and +the Spin adapter's route list records why. + +The draft's adapter capability matrix (declared per-record-class +consistency semantics, durability and retention proofs, activation and +lease qualification) is **not implemented in these PRs** and is tracked +follow-up work. The matrix's motivating rule is preserved for that +follow-up, which is that "has KV" says nothing about whether a revocation +is observable, so eligibility for identity features must eventually be +declared and checked, not assumed. + +## 8. Crate layout and CI + +Host and vendor provider crates live in nested directories grouped by +capability, with flat package names following the existing convention: + +- `crates/device/fastly` is package `trusted-server-device-fastly`. +- `crates/geo/fastly` is package `trusted-server-geo-fastly`. +- `crates/edgecookie/` is the documented home for vendor EC + crates. The directory currently holds only a README, because the + built-in providers live in core and no vendor crate exists yet. + +The draft mandated flat directories (`crates/trusted-server-geo-fastly`) +and banned placeholder directories. The implementation diverges on both +points. Nested directories scale per vendor as providers multiply, package +names already carry the flat convention, and the README stakes out the +location before the first vendor crate lands. Both divergences are +recorded in the revision table. + +Every new crate is in the `.cargo/config.toml` aliases (`check-fastly`, +`clippy-fastly`, `test-fastly`, `build-fastly`), so the provider crates are +linted with `-D warnings` and tested by the same gates as every other +workspace member, closing the PR #838 gap where new crates compiled only +transitively. + +## 9. Behavior preservation notes + +Two defaults chosen for neutrality change effective behavior on existing +Fastly deployments. Both are called out in the migration spec and must be +prominent in release notes: + +- **Bot gate.** The pre-provider EC bot gate required JA4 and platform + class. The default `builtin` classifier is User-Agent only, so the gate + is weaker by default. The stronger gate is available as `[device] +provider = "fastly"` rather than being startup-rejected as the draft + specified. Release notes call out the weaker default rather than + presenting selection alone as authorization. +- **Geo.** With no geo provider, jurisdiction resolution falls to the + required `[geo] default_country`. The permission model constrains the + combination so it cannot silently grant permissions to mis-attributed + traffic. The default must resolve to a real `permissions.yaml` rule, a + deployment running an EC provider without geo must set + `assume_single_jurisdiction = true`, and a failed lookup resolves to the + requires-signal floor instead of the default. The default flip landed in + the same series as those constraints, honoring the draft's sequencing + requirement that the constraint exist before the flip. + +## 10. Testing strategy + +Implemented, in the crates named: + +- Round-trip tests with a non-default provider, proving an opaque + identifier survives read-back byte for byte (`ec/mod.rs`) and the + client-cycle value survives the full scenario as cookie and KV key + (`ec/resolve.rs`). +- Delegation tests proving the injected-provider wrapper forwards + `accepts_id` and `normalize_id_for_kv` to the inner provider, so a + vendor identifier is never dropped by the built-in defaults. +- Gate tests proving the HMAC provider's declared requirement blocks + generation until the permission is set, and that a provider declaring + nothing requires nothing. +- Settings validation tests covering the section 6 table, including the + missing block, the block without a selector, explicit `none`, the stray + block, unknown selector keys for all three concerns, unknown fields in + every section, the deprecated passphrase migration with its both-forms + rejection, `default_country` validation, and the jurisdiction + acknowledgment. +- Geo builder tests showing the default selects no geo, `none` selects no + geo explicitly, and `platform` selects the host implementation. +- Host-signals provider tests covering minting from fingerprints, + deferring without them, and the loud failure of a selected but + uninjected vendor provider. + +Deferred with their features are the fixture-driven provider conformance +suite, legacy-reader tests, and the parity cases for capability-mismatch +startup failures. + +## 11. Implementation order + +As landed: + +1. **PR #1043, the Edge Cookie provider seam.** The trait with recognition + and KV normalization, the global identifier bounds, selection and + validation, the vendor block capture, the deprecated-passphrase + migration, and the round-trip proof with a non-default provider. +2. **PR #1044, device and geo selection.** `DeviceProvider` with the + builtin default and the opt-in Fastly host-evidence provider, + `PlatformGeo` selection with the no-geo default, all four adapters + routed through the shared builders, and the opt-in host-signals EC + provider (carrying the open review question of section 2). +3. **PR #1045, the permission model.** The enforcement point for + `required_permissions`, the `default_country` requirement and + jurisdiction acknowledgment, and the failed-lookup floor. That change + has its own spec, which this document cross-references rather than + restates. + +The draft's step 4 warning (do not flip the geo neutral default before the +permission model exists) was honored. The flip and its constraints landed +together in the permission model change. + +## 12. Divergences from issue #778 + +This spec supersedes #778 on the following points, so implementation has +one acceptance contract: + +| #778 says | This spec says | Why | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Identifier comparison is a provider operation (`keys_equal`) | Comparison is structural. Read-back acceptance and KV normalization route through the provider, so no comparison method exists (§3, §4) | Satisfies the same requirement with no method to leave uncalled | +| A provider can return response headers | Kept, with a production consumer. EC finalization applies them, and the client-cycle path uses them (§4) | The caller the minimalism rule demands landed in the same series | +| One built-in provider (HMAC) preserving today's behavior | HMAC preserved verbatim, plus the opt-in host-signals built-in (open question, §2) and the demo client-cycle provider | Switching semantics beyond shape-based recognition (`legacy_providers`) remain follow-up work with the migration spec | + +## 13. Revision record vs the 2026-07-31 draft + +One row per divergence between the 2026-07-31 draft and the implementation +this revision describes. + +| Draft position | Implemented position | Why | +| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Trait surface is a canonicalizing `parse` returning a typed id, plus `graph_key_suffix`, `cluster_prefix`, and `verify` | `accepts_id` (recognition) plus `normalize_id_for_kv` (KV key form), defaults matching the built-in shape. No typed id, key suffix, cluster capability, or `verify`. `keys_equal` stays out, as the draft required. | Recognition and KV keying are the two operations core performs today. A byte-for-byte round-trip test with a non-default provider pins the contract. | +| `GeneratedEdgeCookie::response_headers` and `IdentityInput.permissions` / `.consent` banned as speculative surface | Shipped with production consumers. Finalization applies provider headers, the resolve path returns them, and the organic mint path populates the input fields. | The client-cycle resolve path landed in the same series and is their caller, satisfying the minimalism rule the ban enforced. | +| Identifier bounds enforced at mint and parse | Enforced at mint (`generate` and the resolve endpoint), cookie read-back, and cookie write. Violations rejected outright, never rewritten. `MAX_EC_ID_LEN` in `ec/cookies.rs`. | Every identifier entry point is covered, and the pre-epic sanitizing rewrite was removed as a silent-divergence hazard. | +| `provider = "none"` is valid alongside `legacy_providers` blocks | `none` (or an omitted selector) with any configured provider block is a startup error. | No `legacy_providers` exists in these PRs, so a block alongside statelessness can only be a mistake. | +| Every selection key is closed and unknown keys are startup errors | Device and geo keys are closed. EC vendor keys are open. Unknown blocks are captured as raw values in core, the adapter deserializes its own block, and a selected key with no injected provider fails loudly. | Core never names a vendor, so a vendor provider adds no core change. | +| Capability mismatch is a startup error at adapter wiring time | Configuration coherence fails at startup. A host-capability mismatch (missing `HostSignals`, uninjected vendor) fails loudly when the provider is built, stopping the request. | The adapter capability declaration that would move the check to startup is deferred with the capability matrix. | +| A minting provider with no identity-graph store is a startup error | Not implemented. `ec_store` stays optional. The resolve endpoint refuses to mint without a graph. The organic path persists rows whenever the graph is configured. | Portability adapters run without platform KV. Whether configuration should force the pairing is follow-up work. | +| `[device] provider = "fastly"` is startup-rejected pending a separate security design | Shipped as a selectable opt-in. The Fastly adapter injects `HostSignals`, the provider strengthens the browser/bot gate, and rows persist derived classes, not raw fingerprints. | Selection is an explicit operator opt-in and the neutral default makes no host fingerprint call. | +| The `host-signals` EC provider is deliberately dropped and its selection rejected | Shipped in PR #1044 as an opt-in built-in that defers with a warning when the host supplies no signals. **Open, flagged for the series review**, not settled either way. | Its identifier shape shares the HMAC grammar, and a sign-off row defers host fingerprint processing, so the review decides whether the provider ships in the series. | +| Geo default flip sequenced into the later permission-model step, with an acknowledgment guard | Landed as specified in the same series, with the default of none, `default_country` required and validated against `permissions.yaml`, the `assume_single_jurisdiction` acknowledgment, and a failed lookup resolving to the requires-signal floor with error logging (`GeoStatus`, resolved in core so all adapters agree). | The permission model shipped in PR #1045, so the constraints exist where the draft required them. | +| All adapters serve the full EC feature set identically | Selector behavior is identical through the shared builders and core constructors. The EC API routes (identify, batch-sync, ec/resolve) are Fastly-only, documented in the Spin route list. | The portability adapters do not yet wire platform KV, and the gap is documented rather than silent. | +| Conformance suite, adapter capability matrix, delimiter-free key grammar, `verify`, `legacy_providers`, `versions` / `mint_version` | None of these are in PR #1043 or #1044. All are tracked follow-up work, deferred, not silently dropped. | The shipped seam did not need them, and each returns with the feature that gives it a production caller, per the spec's own minimalism rule. | +| `required_permissions` removed from the device and geo traits, added to the EC trait only at the permission-model step | Present on all three traits from the start, with empty defaults. Core enforces the EC declaration (gate in `EcContext`, landing in PR #1045). No device or geo enforcement point exists. | One uniform declaration seam, with an empty default that gates nothing, avoids the decorative-gate hazard while keeping the interface stable. The geo circularity stands. | +| Flat crate directories (`crates/trusted-server-geo-fastly`), no placeholder directories | Nested directories per capability (`crates/device/fastly`, `crates/geo/fastly`, `crates/edgecookie/`), flat package names. `crates/edgecookie` ships a README before its first crate. | Nested directories scale per vendor, package names already carry the naming convention, and the README stakes out the vendor location. | diff --git a/docs/superpowers/specs/provider-code-registry.md b/docs/superpowers/specs/provider-code-registry.md new file mode 100644 index 000000000..9cad450fb --- /dev/null +++ b/docs/superpowers/specs/provider-code-registry.md @@ -0,0 +1,30 @@ +# Provider-code registry (normative, append-only, never reused) + +Four-character codes (`[a-z0-9]`, zero-padded) that namespace Edge Cookie +identifiers. Every EC provider MUST allocate a code here before it can +exist: the `EdgeCookieProvider::code()` trait method is mandatory, and core +applies the code as the `{code}~` prefix of every identifier the provider +mints, checks it at read-back, and keys the identity graph with it. A +provider only ever sees its own value part, so identifiers from different +providers can never collide in the cookie, the graph, or a withdrawal, and +every identifier records which provider created it. + +Allocation is a reviewed commit to this file; codes are immutable and never +recycled, including for retired providers. A leading digit is valid. The +tilde separator keeps parsing exact while pre-envelope identifiers remain +deployed: a legacy bare identifier contains no tilde and dual-reads under +the built-in HMAC provider only. + +The class of provider expected to grow this table is one that consumes a +web-browser-supplied unique identifier, arriving either as a new web +platform feature or from a user-installed extension, delivered to the edge +through the client-cycle resolve path and verified by the provider before +minting. + +| Code | Provider | Allocated | Status | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------- | +| `hmac` | Built-in HMAC EC provider. Mints `hmac~<64 hex>.<6 alnum>`; dual-reads its pre-envelope bare form for one release cycle so deployed cookies keep working | 2026-08-02 | active | +| `hs00` | Built-in host-signal EC provider (opt-in; TLS JA4 plus HTTP/2 signals plus client IP) | 2026-08-25 | active | +| `cfix` | Client-fixed demonstration provider (compiled only behind the `client-fixed-demo` cargo feature) | 2026-08-25 | active, test and demo only | +| `51dd` | 51Degrees Identifier (51Did) vendor provider | 2026-08-25 | reserved | +| `t0..` | Prefix family reserved for in-tree test providers (`t0cc`, `t0op`, and similar); never valid in configuration | 2026-08-25 | reserved | diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 7c7e83635..ccd062090 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -12,12 +12,21 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] -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" ec_store = "ec_identity_store" pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # cluster_recheck_secs = 3600 +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Example partner configuration. Replace the token before validating/pushing. # [[ec.partners]] # name = "Example Partner" From cffdf78f0e81bb9122509141e546a11f893745d2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 10:03:47 +0100 Subject: [PATCH 2/3] Add device and geo provider selection with the host-signal Edge Cookie provider Second slice of the PR 838 decomposition. Device classification and geolocation become selectable providers, mirroring the Edge Cookie provider seam: - [device] provider selects the classifier. The built-in default reads the User-Agent alone and makes no host call; the opt-in fastly provider strengthens the browser/bot gate with the host's TLS JA4 and HTTP/2 signals (crates/device/fastly). - [geo] provider selects geolocation. The host platform's lookup is the default, matching the behavior before the selector existed, and provider = "platform" spells the same choice explicitly (crates/geo/fastly wraps the Fastly host lookup behind the PlatformGeo trait). provider = "none" opts out entirely, so a client IP is never sent to any host geo service. The disabled-by-default flip ships with the permission model in the next slice, which adds the jurisdiction baseline that makes a no-geo deployment viable. - Every adapter routes its host geo through the same build_geo_provider selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider identically, so the selector is not a Fastly-only behavior. - The provider configuration sections ([device], [geo], [ec.providers.hmac], [ec.providers.host-signals]) reject unknown keys at startup, so a mistyped key fails loudly instead of silently selecting a default. - The host-signal Edge Cookie provider arrives with the capability it needs: the Fastly adapter injects the TLS/HTTP-2 signals as a HostSignals service, and the provider mints from them plus the client IP. With no host signals at all it defers with a warning rather than degrading to an IP-only identifier. - Device signals move to a field-based DeviceSignals derived in the adapter (derive_ua_only for hosts without host signals). - The new crates join the fastly cargo aliases so they build, lint, and test in CI rather than compiling only transitively. --- .cargo/config.toml | 8 +- Cargo.lock | 19 + Cargo.toml | 4 + crates/device/README.md | 10 + crates/device/fastly/Cargo.toml | 18 + crates/device/fastly/src/lib.rs | 97 +++++ crates/fastly.toml | 13 + crates/geo/README.md | 20 + crates/geo/fastly/Cargo.toml | 19 + crates/geo/fastly/src/lib.rs | 45 +++ crates/trusted-server-adapter-axum/src/app.rs | 2 +- .../src/platform.rs | 14 +- .../src/app.rs | 8 +- .../src/platform.rs | 11 +- .../trusted-server-adapter-fastly/Cargo.toml | 2 + .../trusted-server-adapter-fastly/src/app.rs | 33 +- .../trusted-server-adapter-fastly/src/main.rs | 87 ++++- .../src/platform.rs | 39 +- crates/trusted-server-adapter-spin/src/app.rs | 18 +- .../src/platform.rs | 16 +- crates/trusted-server-core/src/ec/device.rs | 350 +++++++++++++++--- crates/trusted-server-core/src/ec/mod.rs | 3 +- crates/trusted-server-core/src/ec/provider.rs | 148 +++++++- crates/trusted-server-core/src/edge_cookie.rs | 11 +- crates/trusted-server-core/src/evidence.rs | 14 + crates/trusted-server-core/src/geo.rs | 66 ---- .../trusted-server-core/src/platform/mod.rs | 84 +++++ .../src/platform/traits.rs | 7 + .../trusted-server-core/src/platform/types.rs | 35 +- crates/trusted-server-core/src/settings.rs | 230 +++++++++++- .../configs/trusted-server.integration.toml | 6 + trusted-server.example.toml | 12 + 32 files changed, 1241 insertions(+), 208 deletions(-) create mode 100644 crates/device/README.md create mode 100644 crates/device/fastly/Cargo.toml create mode 100644 crates/device/fastly/src/lib.rs create mode 100644 crates/fastly.toml create mode 100644 crates/geo/README.md create mode 100644 crates/geo/fastly/Cargo.toml create mode 100644 crates/geo/fastly/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..bc8c20d71 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/Cargo.lock b/Cargo.lock index e29380b77..b186502f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5376,6 +5376,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5489,6 +5491,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "trusted-server-device-fastly" +version = "0.1.0" +dependencies = [ + "fastly", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-geo-fastly" +version = "0.1.0" +dependencies = [ + "error-stack", + "fastly", + "trusted-server-core", +] + [[package]] name = "trusted-server-integration-tests" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 25c367181..00d3d732d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -109,6 +111,8 @@ toml = "1.1" toml_edit = "0.23.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-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/device/README.md b/crates/device/README.md new file mode 100644 index 000000000..3aa4cb04a --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device providers + +Device-detection provider crates live here, one per vendor. The Fastly provider +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 fingerprints; future vendor providers (for example +`crates/device/`) slot in alongside it. + +The built-in default provider (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor provider via +`build_device_provider`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 000000000..3ff1b6e5e --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals." +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 } +fastly = { workspace = true } diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 000000000..822e4cc37 --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,97 @@ +//! The Fastly device provider and host-signal capture. +//! +//! [`FastlyDeviceProvider`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 fingerprints, for deployments on Fastly +//! Compute. It is selected by `[device] provider = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those fingerprints from a live Fastly request +//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can +//! be shared as an injected [`HostSignals`] service that outlives the borrow of +//! the request. Capturing through the SDK is why this crate depends on the +//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the +//! accessors return `None`, so classification degrades to User-Agent only. The +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceProvider`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client fingerprints captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 fingerprints once through the Fastly SDK and +/// owns them, so the value can be injected as a [`HostSignals`] service that +/// outlives the borrow of the request it was captured from. Off-host the SDK +/// accessors return `None`, so the signals are simply absent. +#[derive(Debug, Clone, Default)] +pub struct FastlyHostSignals { + ja4: Option, + h2: Option, +} + +impl FastlyHostSignals { + /// Builds host signals from already-captured fingerprint values. + /// + /// Use this when the adapter has read the fingerprints once (for example + /// into the client metadata, or from the trusted internal headers the entry + /// point injects) and wants to share them without another SDK call. + #[must_use] + pub fn new(ja4: Option, h2: Option) -> Self { + Self { ja4, h2 } + } + + /// Captures the TLS JA4 and HTTP/2 fingerprints from a live Fastly request. + #[must_use] + pub fn from_request(req: &FastlyRequest) -> Self { + Self { + ja4: req.get_tls_ja4().map(str::to_string), + h2: req.get_client_h2_fingerprint().map(str::to_string), + } + } +} + +impl HostSignals for FastlyHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } +} + +/// The Fastly device provider, opt-in via `[device] provider = "fastly"`. +/// +/// Classifies a request with the fingerprint-strengthened +/// [`DeviceSignals::derive`], reading the User-Agent from its injected +/// [`RequestInfo`] and the TLS/HTTP-2 fingerprints from its injected +/// [`HostSignals`], so the browser/bot gate is backed by the live request. +pub struct FastlyDeviceProvider { + host_signals: Arc, +} + +impl FastlyDeviceProvider { + /// Creates the provider with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceProvider for FastlyDeviceProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 000000000..718e87ad3 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..f3c5d11fb --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,20 @@ +# Geo providers + +Geo and IP-intelligence provider crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + provider for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_provider`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo providers (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] provider` setting. + +Whatever the source, a provider returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other providers feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no +location) both live in `trusted-server-core`, so the default deployment resolves +no location until a provider is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 000000000..d1e3c4f7c --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo provider backed by the Fastly geolocation API." +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 } +error-stack = { workspace = true } +fastly = { workspace = true } diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 000000000..a93f7dd72 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,45 @@ +//! The Fastly host geo provider. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo provider, +//! injected by the Fastly adapter via `build_geo_provider`; selecting a vendor +//! geo provider replaces it. +//! +//! Unlike the pure-logic device provider, this crate calls the Fastly geo SDK +//! directly, so it depends on the `fastly` crate and builds only for the +//! `wasm32-wasip1` target. The platform-neutral `PlatformGeo` trait and the +//! `DisabledGeo` default both live in `trusted-server-core`. + +use std::net::IpAddr; + +use error_stack::Report; +use fastly::geo::{Geo, geo_lookup}; +use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo}; + +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// The host platform geo provider for Fastly Compute. The adapter injects it via +/// `build_geo_provider`; selecting a vendor geo provider replaces it. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 4b71d07ce..d125c9cd5 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -133,7 +133,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..4886bd6e4 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -533,7 +533,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route /// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!( @@ -578,9 +581,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 86ac86987..02cbbe724 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -127,8 +127,8 @@ fn build_state_with_settings( // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +fn build_per_request_services(ctx: &RequestContext, settings: &Settings) -> RuntimeServices { + build_runtime_services(ctx, settings) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, @@ -178,7 +178,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(&ctx, &s.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -376,7 +376,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(&ctx, &state.settings); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..658915b73 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -598,7 +598,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -633,7 +636,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] provider selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_provider(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -641,7 +646,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) .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 47cc609b2..d0a43e670 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -29,6 +29,8 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-device-fastly = { workspace = true } +trusted-server-geo-fastly = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index f9a44b33d..1b5777f78 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -119,7 +119,11 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, build_geo_provider, +}; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -261,6 +265,23 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); + // The TLS JA4 and HTTP/2 fingerprints arrive as trusted internal headers + // injected by the entry point. They build the host-signal service a + // host-signal provider reads; Fastly always supplies the capability, so the + // service is always set even when a request carried no fingerprint. + let tls_ja4 = ctx + .request() + .headers() + .get("x-ts-tls-ja4") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let h2_fingerprint = ctx + .request() + .headers() + .get("x-ts-h2-fingerprint") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) @@ -272,9 +293,13 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_provider( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))) .build() } @@ -397,7 +422,7 @@ fn build_ec_request_state( req: &Request, ) -> EcRequestState { let device_signals = device_signals_for(req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", @@ -709,7 +734,7 @@ async fn run_named_route( /// response finalization. fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index ca5607f9b..d9b877176 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,14 +5,16 @@ use edgezero_adapter_fastly::request::into_core_request; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; +use edgezero_core::http::{ + HeaderMap, HeaderValue, Request as HttpRequest, Response as HttpResponse, header, +}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals, build_device_provider}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -20,12 +22,14 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::build_geo_provider; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -161,7 +165,42 @@ fn edgezero_main(mut req: FastlyRequest) { // accessors only return real values on the client request, so store them in // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req); - let device_signals = derive_device_signals(&req); + + // Strip and re-inject the TLS JA4 and HTTP/2 fingerprints from the + // authoritative Fastly SDK values, under the same trust model, so the + // EdgeZero app path can build the host-signal service from these internal + // headers (the SDK accessors return real values only on the live client + // request, not on a request rebuilt from EdgeZero HTTP types). + req.remove_header("x-ts-tls-ja4"); + req.remove_header("x-ts-h2-fingerprint"); + // Take ownership before setting: unlike the static TLS protocol/cipher + // names, these accessors borrow the request, which would otherwise conflict + // with the mutable `set_header`. + if let Some(ja4) = req.get_tls_ja4().map(str::to_string) { + req.set_header("x-ts-tls-ja4", ja4); + } + if let Some(h2) = req.get_client_h2_fingerprint().map(str::to_string) { + req.set_header("x-ts-h2-fingerprint", h2); + } + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + // Reuse the settings snapshot already loaded for the app state rather than + // fetching and validating the config-store blob a second time per request. + let device_signals = match settings_snapshot.as_deref() { + Some(settings) => derive_device_signals(settings, &req), + None => { + log::warn!( + "EdgeZero device signals: settings unavailable, using UA-only classification" + ); + DeviceSignals::derive_ua_only(req.get_header_str("user-agent").unwrap_or("")) + } + }; // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such @@ -279,8 +318,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_provider(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -486,16 +529,32 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { - let ua = req.get_header_str("user-agent").unwrap_or(""); - let ja4 = req.get_tls_ja4(); - let h2_fp = req.get_client_h2_fingerprint(); - - DeviceSignals::derive(ua, ja4, h2_fp) +/// The providers read request data from injected services: device classification +/// reads only the User-Agent, borrowed here through a `BorrowedRequestInfo`, while the +/// Fastly provider also reads the TLS/H2 fingerprints captured into a +/// [`FastlyHostSignals`]. The Fastly provider, and so the fingerprint capture, is +/// built only when selected, so the default request path makes no Fastly-specific +/// fingerprint call. +pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> DeviceSignals { + let mut headers = HeaderMap::new(); + if let Some(value) = req + .get_header_str(header::USER_AGENT.as_str()) + .and_then(|user_agent| HeaderValue::from_str(user_agent).ok()) + { + headers.insert(header::USER_AGENT, value); + } + let client_ip = req + .get_client_ip_addr() + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let request_info = BorrowedRequestInfo::new(&client_ip, Some(&headers)); + build_device_provider(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceProvider::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..626461e60 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -2,21 +2,19 @@ //! `trusted-server-core::platform`. use std::io::Read as _; -use std::net::IpAddr; use std::sync::Arc; use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, @@ -666,33 +664,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo provider now lives in its own crate, +/// `trusted-server-geo-fastly`, so every provider implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's existing call sites keep +/// referring to it through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; /// Extract [`ClientInfo`] from the original Fastly request. /// diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 06bb1a15a..ed45bed90 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -503,7 +503,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -515,7 +515,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -548,7 +548,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_runtime_services(&ctx, &s.settings); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -586,7 +586,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_runtime_services(&ctx, &s.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -621,7 +621,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -634,7 +634,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -647,7 +647,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -664,7 +664,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_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -680,7 +680,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..f91b1e8e6 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -715,7 +715,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -744,7 +747,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::new(NullGeo), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -994,7 +1003,8 @@ 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); + let services = + build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586..daad89caf 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,9 +1,11 @@ //! Device signal derivation for bot detection and browser classification. //! -//! All functions in this module are pure computations — no KV I/O or Fastly -//! SDK calls. The Fastly adapter extracts raw strings from the request -//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes -//! them here for classification. +//! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection: its +//! constructor takes the services it reads (the [`RequestInfo`] for the +//! User-Agent, and on a fingerprinting host the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS/H2 fingerprints), +//! and classifies the request from them. //! //! # Signals //! @@ -18,6 +20,8 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; +use crate::evidence::RequestInfo; +use crate::settings::Settings; /// Device signals derived from a single request. /// @@ -33,18 +37,48 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, - /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string. + /// SHA256 prefix (12 hex chars) of raw H2 SETTINGS fingerprint. pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. pub known_browser: Option, + /// Whether the request looks like a real browser, used to gate Edge Cookie + /// writes. Computed by the producing provider: the built-in provider uses a + /// User-Agent-only heuristic, while the Fastly provider strengthens it with + /// the TLS/H2 fingerprints. + pub looks_like_browser: bool, } impl DeviceSignals { - /// Derives all device signals from raw request data. + /// Derives device signals from the User-Agent alone, with no + /// host-specific TLS or HTTP/2 evidence. + /// + /// This is the default path: it touches no Fastly-specific API, so a + /// default deployment stays host-neutral. `ja4_class` and `h2_fp_hash` are + /// left absent, and the browser/bot decision uses a User-Agent-only + /// heuristic (`looks_like_browser_from_ua`). + #[must_use] + pub fn derive_ua_only(ua: &str) -> Self { + let platform_class = parse_platform_class(ua); + let looks_like_browser = looks_like_browser_from_ua(ua, platform_class.as_deref()); + + Self { + is_mobile: parse_is_mobile(ua), + ja4_class: None, + platform_class, + h2_fp_hash: None, + known_browser: None, + looks_like_browser, + } + } + + /// Derives device signals from the User-Agent strengthened with the + /// host's TLS/H2 fingerprints. /// /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string - /// from `req.get_client_h2_fingerprint()`. + /// from `req.get_client_h2_fingerprint()`. These fingerprints are + /// host-specific (Fastly), so only the opt-in Fastly device provider + /// uses this path; the browser/bot gate then requires a TLS fingerprint. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -52,6 +86,12 @@ impl DeviceSignals { let platform_class = parse_platform_class(ua); let h2_fp_hash = h2_fp.map(compute_h2_fp_hash); let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref()); + // The fingerprint-strengthened gate: a real browser produces a valid + // TLS fingerprint and a recognizable UA platform. Raw HTTP clients + // (curl, Python requests, Go net/http, headless scrapers) lack one or + // both. This is intentionally aimed at filtering obvious missing-signal + // traffic, not at resisting deliberate JA4 + UA spoofing. + let looks_like_browser = ja4_class.is_some() && platform_class.is_some(); Self { is_mobile, @@ -59,32 +99,10 @@ impl DeviceSignals { platform_class, h2_fp_hash, known_browser, + looks_like_browser, } } - /// Returns `true` when the request looks like a real browser. - /// - /// Checks for the presence of recognizable signals rather than matching - /// against a hardcoded signal allowlist. Real browsers always - /// produce a valid TLS probabilistic identifier (`ja4_class`) and a recognizable UA - /// platform string (`platform_class`). Raw HTTP clients (curl, Python - /// requests, Go net/http, headless scrapers) typically lack one or both. - /// - /// # Threat model - /// - /// This heuristic is intentionally aimed at filtering obvious - /// missing-signal traffic, not at resisting deliberate spoofing. A bot - /// that forges plausible JA4 and UA inputs may still pass; deeper - /// consistency checks can be added later if product requirements demand - /// stronger spoof resistance. - /// - /// `known_browser` is still computed and stored on [`KvDevice`] for - /// analytics but does not gate identity operations. - #[must_use] - pub fn looks_like_browser(&self) -> bool { - self.ja4_class.is_some() && self.platform_class.is_some() - } - /// Converts these signals into a [`KvDevice`] for KV storage. #[must_use] pub fn to_kv_device(&self) -> KvDevice { @@ -98,6 +116,80 @@ impl DeviceSignals { } } +/// A strategy for classifying a request into [`DeviceSignals`]. +/// +/// Implementations are selected by configuration. The built-in +/// [`BuiltinDeviceProvider`] is the default; a deployment can switch to another +/// provider without changing call sites. +/// +/// These signals serve identity gating and bot detection, not bid enrichment. +/// [`DeviceSignals`] deliberately carries only the coarse browser and bot +/// classification the Edge Cookie gate needs, not a full device-detection +/// result such as make, model, OS version, or screen size. A richer device +/// model for the ad request is a separate concern. +pub trait DeviceProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Classifies the request into [`DeviceSignals`], reading the request data + /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any + /// host signals injected into its constructor). + /// + /// Device signals gate identity operations and must always yield a value, + /// so this is infallible: a provider that cannot determine a signal returns + /// the unknown variant rather than failing the request. + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; +} + +/// The built-in device provider, the default. +/// +/// Derives [`DeviceSignals`] from the User-Agent alone via +/// [`DeviceSignals::derive_ua_only`], touching no host-specific API. It reads +/// only [`RequestInfo::user_agent`] and never a host fingerprint, so the default +/// request path stays host-neutral. +#[derive(Debug, Default)] +pub struct BuiltinDeviceProvider; + +impl BuiltinDeviceProvider { + /// Creates the built-in provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl DeviceProvider for BuiltinDeviceProvider { + fn id(&self) -> &'static str { + "builtin" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } +} + +/// Selects the device provider named by the `[device] provider` selector. +/// +/// Returns the built-in User-Agent-only provider unless the `fastly` selector is +/// set, in which case it builds the host-specific provider through the +/// `build_fastly` factory the adapter supplies. The factory runs only when that +/// provider is selected, so the default path captures no host fingerprints (see +/// [`BuiltinDeviceProvider`] for the host-neutral default). A +/// selected-but-unknown provider is rejected at startup by +/// [`DeviceConfig::validate_provider_selection`](crate::settings::DeviceConfig::validate_provider_selection), +/// so this falls back to the built-in provider for that case. +#[must_use] +pub fn build_device_provider( + settings: &Settings, + build_fastly: impl FnOnce() -> Box, +) -> Box { + match settings.device.provider_key() { + "fastly" => build_fastly(), + _ => Box::new(BuiltinDeviceProvider::new()), + } +} + /// Device is a desktop (confirmed via UA platform token). const MOBILE_DESKTOP: u8 = 0; /// Device is a mobile (confirmed via UA mobile token). @@ -146,11 +238,58 @@ fn parse_platform_class(ua: &str) -> Option { None } -/// Extracts Section 1 from a full JA4 string. +/// Decides whether a request looks like a real browser from the User-Agent +/// alone, with no TLS or HTTP/2 evidence. +/// +/// A real browser sends the `Mozilla/` token every major engine still emits and +/// a recognizable platform string (so `platform_class` is present), and is not +/// an obvious bot or command-line client. Raw HTTP clients (curl, Python +/// requests, Go net/http) carry no platform token, so they fail the +/// `platform_class` check; declared crawlers are caught by [`looks_like_bot_ua`]. +/// +/// # Threat model +/// +/// This is the default, host-neutral gate. It filters obvious non-browser +/// traffic but does not resist a bot that forges a complete browser +/// User-Agent. The opt-in Fastly device provider strengthens the gate with the +/// TLS/H2 fingerprints for deployments that need it. +#[must_use] +fn looks_like_browser_from_ua(ua: &str, platform_class: Option<&str>) -> bool { + platform_class.is_some() && ua.contains("Mozilla/") && !looks_like_bot_ua(ua) +} + +/// Returns `true` when the User-Agent declares a known bot, crawler, or +/// non-browser HTTP client. +/// +/// Matches common self-identifying markers case-insensitively. The `bot` marker +/// covers `Googlebot`, `bingbot`, and similar; the library markers cover HTTP +/// clients that set a recognizable platform token. +#[must_use] +fn looks_like_bot_ua(ua: &str) -> bool { + const BOT_MARKERS: &[&str] = &[ + "bot", + "crawl", + "spider", + "slurp", + "curl", + "wget", + "python-requests", + "go-http-client", + "okhttp", + "java/", + "headlesschrome", + "phantomjs", + "scrapy", + ]; + let lower = ua.to_ascii_lowercase(); + BOT_MARKERS.iter().any(|marker| lower.contains(marker)) +} + +/// Extracts Section 1 from a full JA4 fingerprint. /// /// JA4 format: `section1_section2_section3` separated by underscores. /// Section 1 identifies browser family (cipher count, extension count, -/// ALPN) without uniquely identifying a device. +/// ALPN) without uniquely fingerprinting a device. /// /// Returns `None` if the input is empty or has no underscore-delimited /// section. @@ -164,7 +303,7 @@ fn extract_ja4_section1(full_ja4: &str) -> Option { } /// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2 -/// SETTINGS string. +/// SETTINGS fingerprint string. /// /// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`. #[must_use] @@ -175,7 +314,7 @@ fn compute_h2_fp_hash(raw_h2_fp: &str) -> String { hex::encode(&digest[..6]) } -/// Known browser signal allowlist. +/// Known browser fingerprint allowlist. /// /// Each entry is `(ja4_class, h2_fp_prefix, known_browser)`. /// `h2_fp_prefix` is the raw H2 SETTINGS string (not the hash) — we @@ -191,7 +330,7 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true), ]; -/// Returns H2 SETTINGS hashes for the known browser allowlist. +/// Returns H2 fingerprint hashes for the known browser allowlist. /// /// Computed once on first call and cached via `OnceLock`. fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> { @@ -230,6 +369,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -366,7 +506,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Chrome signals should be recognized" + "Chrome fingerprint should be recognized" ); } @@ -377,7 +517,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Safari signals should be recognized" + "Safari fingerprint should be recognized" ); } @@ -388,7 +528,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Firefox signals should be recognized" + "Firefox fingerprint should be recognized" ); } @@ -523,7 +663,7 @@ mod tests { Some("1:65536;2:0;4:6291456;6:262144"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "Chrome/Mac should look like a browser" ); } @@ -537,8 +677,8 @@ mod tests { Some("99:99;88:88"), ); assert!( - signals.looks_like_browser(), - "unknown signal combination with valid JA4 + platform should pass" + signals.looks_like_browser, + "unknown fingerprint with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); } @@ -547,17 +687,17 @@ mod tests { fn looks_like_browser_rejects_bot() { let signals = DeviceSignals::derive(BOT_UA, None, None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "bot with no JA4 and no platform should be rejected" ); } #[test] fn looks_like_browser_rejects_missing_ja4() { - // Real UA but no JA4 value (e.g. HTTP/1.1 or missing SDK support) + // Real UA but no TLS fingerprint (e.g. HTTP/1.1 or missing SDK support) let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536")); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "missing JA4 should be rejected even with valid UA" ); } @@ -567,8 +707,128 @@ mod tests { // Has JA4 but unrecognizable UA let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "unrecognizable UA should be rejected even with JA4" ); } + + #[test] + fn derive_ua_only_accepts_real_browsers_without_fingerprints() { + for ua in [ + CHROME_MAC_UA, + SAFARI_IOS_UA, + FIREFOX_MAC_UA, + CHROME_ANDROID_UA, + CHROME_WINDOWS_UA, + ] { + let signals = DeviceSignals::derive_ua_only(ua); + assert!( + signals.looks_like_browser, + "a real browser UA should pass the UA-only gate: {ua}" + ); + assert!( + signals.ja4_class.is_none() && signals.h2_fp_hash.is_none(), + "the UA-only path must not record any TLS/H2 evidence" + ); + } + } + + #[test] + fn derive_ua_only_rejects_bots_and_http_clients() { + // Declared crawlers and CLI/library clients must not pass the gate. + for ua in [ + BOT_UA, + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "curl/8.4.0", + "python-requests/2.31.0", + "Go-http-client/2.0", + "", + ] { + assert!( + !DeviceSignals::derive_ua_only(ua).looks_like_browser, + "a non-browser client should fail the UA-only gate: {ua:?}" + ); + } + } + + #[test] + fn derive_ua_only_rejects_a_browser_ua_that_declares_a_bot() { + // Newer crawlers send a full browser UA with a platform token; the bot + // marker must still reject them. + let googlebot_mobile = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36 \ + (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + assert!( + !DeviceSignals::derive_ua_only(googlebot_mobile).looks_like_browser, + "a browser-shaped UA declaring Googlebot should be rejected" + ); + } + + #[test] + fn builtin_device_provider_is_ua_only() { + let provider = BuiltinDeviceProvider::new(); + assert_eq!(provider.id(), "builtin"); + + // The built-in provider classifies from the User-Agent in the request + // info passed to `detect` alone, recording no host fingerprint. + let request_info = request_info_with_ua(CHROME_MAC_UA); + let signals = provider.detect(&request_info); + assert_eq!( + signals, + DeviceSignals::derive_ua_only(CHROME_MAC_UA), + "the built-in provider should classify from the User-Agent only" + ); + assert!( + signals.ja4_class.is_none(), + "the built-in provider must not record a JA4 class" + ); + } + + /// Builds request info carrying the given User-Agent, for provider tests. + fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_str(user_agent) + .expect("should build a valid User-Agent header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + /// A stand-in for the host-specific provider the adapter injects, so the + /// selection logic can be tested in core without the Fastly provider crate. + struct StubFastlyProvider; + + impl DeviceProvider for StubFastlyProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, _request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only("") + } + } + + #[test] + fn build_device_provider_defaults_to_builtin_and_selects_injected() { + // The default selector returns the built-in provider, ignoring the + // injected candidate. + let settings = crate::settings::Settings::default(); + let default = build_device_provider(&settings, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!(default.id(), "builtin", "no selector should be UA-only"); + + // The `fastly` selector returns the provider the adapter's factory builds. + let mut fastly = crate::settings::Settings::default(); + fastly.device.provider = Some("fastly".to_owned()); + let selected = build_device_provider(&fastly, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!( + selected.id(), + "fastly", + "the fastly selector should use the injected provider" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 6bf07625f..d1f774b66 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -241,9 +241,10 @@ impl EcContext { // 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 host_signals = services.host_signals(); let ec_provider = services.ec_provider(); let selected_provider: Option> = - build_provider(&settings.ec, ec_provider.clone())?.map(Arc::from); + build_provider(&settings.ec, host_signals, ec_provider)?.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 diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index ec6eeef3c..1d7fb7de7 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -2,8 +2,8 @@ //! //! 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 +//! needs (for example [`RequestInfo`] for the client IP, or [`HostSignals`] for +//! the TLS/HTTP-2 fingerprints)//! (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. //! @@ -17,7 +17,7 @@ use error_stack::Report; use crate::consent::ConsentContext; use crate::error::TrustedServerError; -use crate::evidence::RequestInfo; +use crate::evidence::{HostSignals, RequestInfo}; use crate::redacted::Redacted; use crate::settings::Ec; @@ -272,23 +272,84 @@ impl EdgeCookieProvider for HmacProvider { } } +/// The built-in host-signal Edge Cookie provider. +/// +/// Derives the identifier from the host fingerprints (TLS JA4 and HTTP/2, read +/// from the injected [`HostSignals`]) plus the client IP (from [`RequestInfo`]), +/// keyed by the configured passphrase. It is host-agnostic: it depends on the +/// `HostSignals` capability, so any host that supplies one can use it. A host +/// that supplies no `HostSignals` cannot build it, and the request stops. +#[derive(Debug, Clone)] +pub struct HostSignalProvider { + passphrase: Redacted, + host_signals: Arc, +} + +impl HostSignalProvider { + /// Creates the provider with the passphrase and its injected host signals. + #[must_use] + pub fn new(passphrase: Redacted, host_signals: Arc) -> Self { + Self { + passphrase, + host_signals, + } + } +} + +impl EdgeCookieProvider for HostSignalProvider { + fn id(&self) -> &'static str { + "host-signals" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("hs00") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let ja4 = self.host_signals.ja4().unwrap_or_default(); + let h2 = self.host_signals.h2().unwrap_or_default(); + // With no fingerprint at all, minting would silently degrade to an + // IP-only identifier under the host-signals name. Defer instead: no + // identity this request, and the request proceeds. + if ja4.is_empty() && h2.is_empty() { + log::warn!("Host-signal EC provider found no TLS/HTTP-2 fingerprints; deferring"); + return Ok(GeneratedEdgeCookie::default()); + } + let id = generation::generate_hmac_ec_id( + self.passphrase.expose(), + &[ja4, h2, 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 +/// This is the composition root for the built-in providers: the adapter supplies +/// the [`HostSignals`] when the host can produce them, and this constructs the +/// selected provider. 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. +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider requires +/// a service the host did not supply (for example the host-signal provider on a +/// host that exposes no [`HostSignals`]), or when a selected vendor provider is +/// not injected by the adapter, so a misconfigured deployment fails loudly +/// rather than minting a degraded identifier or silently running stateless. pub fn build_provider( ec: &Ec, + host_signals: Option>, injected: Option>, ) -> Result>, Report> { let Some(key) = ec.provider.as_deref() else { @@ -302,6 +363,18 @@ pub fn build_provider( .hmac .as_ref() .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + "host-signals" => { + let signals = host_signals.ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "The host-signals Edge Cookie provider requires a host that supplies \ + TLS/HTTP-2 fingerprints, which this host does not" + .to_owned(), + }) + })?; + ec.providers.host_signals.as_ref().map(|config| { + Box::new(HostSignalProvider::new(config.passphrase.clone(), signals)) 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. @@ -366,6 +439,7 @@ impl EdgeCookieProvider for SharedProvider { #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; #[test] fn split_provider_code_separates_coded_and_legacy_forms() { @@ -421,6 +495,26 @@ mod tests { Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) } + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned(), http::HeaderMap::new()) + } + + /// Test host signals with fixed JA4/H2 values. + #[derive(Debug)] + struct TestHostSignals { + ja4: Option, + h2: Option, + } + + impl HostSignals for TestHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } + } + #[test] fn default_id_semantics_match_the_builtin_shape() { let provider = HmacProvider::new(test_passphrase()); @@ -500,6 +594,40 @@ mod tests { ); } + #[test] + fn host_signal_provider_mints_from_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), + h2: Some("1:65536;4:6291456".to_owned()), + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_some(), + "the host-signal provider mints an identifier from the fingerprints" + ); + } + + #[test] + fn host_signal_provider_defers_without_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "with no host fingerprints the provider should defer rather than mint an IP-only identifier" + ); + } + #[test] fn a_selected_but_uninjected_vendor_provider_fails_loudly() { let ec = Ec { @@ -507,7 +635,7 @@ mod tests { ..Ec::default() }; - let err = build_provider(&ec, None) + let err = build_provider(&ec, None, None) .expect_err("selecting a provider the adapter does not inject should error"); assert!( err.to_string().contains("acme"), diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index e77f1a82c..d53b118aa 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -54,13 +54,18 @@ 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) = build_provider( + &settings.ec, + services.host_signals(), + 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. + // The provider reads request data (the client IP, and on a fingerprinting + // host the TLS/HTTP-2 signals) 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 diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 78e0d21ca..804251d0b 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -216,6 +216,20 @@ impl RequestInfo for BorrowedRequestInfo<'_> { } } +/// Host-computed client fingerprints that are not carried in request headers. +/// +/// A host that can compute them supplies an implementation (Fastly exposes the +/// TLS JA4 and HTTP/2 fingerprints). A provider that needs them takes +/// `Arc` in its constructor; on a host that supplies none, the +/// provider cannot be built and the request stops. +pub trait HostSignals: Send + Sync + core::fmt::Debug { + /// The full JA4 TLS fingerprint, or `None` when unavailable. + fn ja4(&self) -> Option<&str>; + + /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + fn h2(&self) -> Option<&str>; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f5..22a37ad2c 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -59,39 +59,6 @@ fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderNa } } -use std::collections::HashSet; -use std::sync::LazyLock; - -/// EU-27 + EEA-3 (Iceland, Liechtenstein, Norway) + UK (UK GDPR). -/// -/// Two-letter ISO 3166-1 alpha-2 country codes for jurisdictions where GDPR -/// or equivalent legislation applies. Used to infer GDPR applicability from -/// IP-derived geolocation when a more authoritative signal (e.g. TCF consent -/// string) is not yet available. -static GDPR_COUNTRIES: LazyLock> = LazyLock::new(|| { - [ - // EU-27 - "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", - "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", - // EEA (non-EU) - "IS", "LI", "NO", // UK GDPR - "GB", - ] - .into_iter() - .collect() -}); - -/// Returns `true` if the given two-letter country code falls under GDPR -/// jurisdiction (EU-27, EEA, or UK). -/// -/// The comparison is case-insensitive. Returns `false` for empty or -/// unrecognised codes. -#[must_use] -pub fn is_gdpr_country(country_code: &str) -> bool { - let upper = country_code.to_ascii_uppercase(); - GDPR_COUNTRIES.contains(upper.as_str()) -} - #[cfg(test)] mod tests { use super::*; @@ -217,39 +184,6 @@ mod tests { ); } - #[test] - fn is_gdpr_country_detects_eu_members() { - assert!(is_gdpr_country("DE"), "Germany is EU"); - assert!(is_gdpr_country("FR"), "France is EU"); - assert!(is_gdpr_country("IT"), "Italy is EU"); - } - - #[test] - fn is_gdpr_country_detects_eea_and_uk() { - assert!(is_gdpr_country("NO"), "Norway is EEA"); - assert!(is_gdpr_country("IS"), "Iceland is EEA"); - assert!(is_gdpr_country("GB"), "UK has UK GDPR"); - } - - #[test] - fn is_gdpr_country_rejects_non_gdpr() { - assert!(!is_gdpr_country("US"), "US is not GDPR"); - assert!(!is_gdpr_country("CN"), "China is not GDPR"); - assert!(!is_gdpr_country("BR"), "Brazil is not GDPR"); - } - - #[test] - fn is_gdpr_country_is_case_insensitive() { - assert!(is_gdpr_country("de"), "lowercase should match"); - assert!(is_gdpr_country("De"), "mixed case should match"); - } - - #[test] - fn is_gdpr_country_handles_empty_and_unknown() { - assert!(!is_gdpr_country(""), "empty string is not GDPR"); - assert!(!is_gdpr_country("XX"), "unknown code is not GDPR"); - } - #[test] fn set_response_headers_omits_region_when_none() { let geo = GeoInfo { diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1c5bf4c2a..1bb200555 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -76,6 +76,48 @@ pub use types::{ /// Default first-byte timeout for platform backends. pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); +use std::net::IpAddr; +use std::sync::Arc; + +use error_stack::Report; + +use crate::settings::Settings; + +/// A geo provider that resolves nothing. +/// +/// Installed when `[geo] provider = "none"` is selected, so a client IP is +/// never sent to any host geo service. Every geo consumer already treats +/// [`GeoInfo`] as optional, so a `None` result degrades gracefully (the +/// jurisdiction is unknown, the auction omits geo, and so on). +pub struct DisabledGeo; + +impl PlatformGeo for DisabledGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +/// Selects the geo provider named by the `[geo] provider` selector. +/// +/// The host platform's geo lookup is the default: with no selector, +/// `host_default` (the adapter's platform geo implementation) resolves the +/// location, matching the behavior before the selector existed, and +/// `provider = "platform"` spells the same choice explicitly. Selecting +/// `provider = "none"` returns [`DisabledGeo`] instead, so a client IP is +/// never sent to any host geo service. A selected-but-unknown provider is +/// rejected at startup by +/// [`GeoConfig::validate_provider_selection`](crate::settings::GeoConfig::validate_provider_selection). +#[must_use] +pub fn build_geo_provider( + settings: &Settings, + host_default: Arc, +) -> Arc { + match settings.geo.provider.as_deref() { + Some("none") => Arc::new(DisabledGeo), + _ => host_default, + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr}; @@ -168,6 +210,48 @@ mod tests { assert!(result.is_none(), "should return None when no IP is present"); } + #[test] + fn build_geo_provider_defaults_to_the_host_geo() { + let settings = Settings::default(); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + Arc::ptr_eq(&host, &selected), + "default settings should use the host geo" + ); + } + + #[test] + fn build_geo_provider_none_selects_no_geo() { + let mut settings = Settings::default(); + settings.geo.provider = Some("none".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + !Arc::ptr_eq(&host, &selected), + "provider none should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the disabled geo provider should resolve nothing" + ); + } + + #[test] + fn build_geo_provider_uses_host_geo_when_platform_is_selected() { + let mut settings = Settings::default(); + settings.geo.provider = Some("platform".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + Arc::ptr_eq(&host, &selected), + "the platform selector should use the host geo" + ); + } + #[test] fn runtime_services_with_kv_store_replaces_only_the_new_clone() { let services = noop_services_with_client_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7))); diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index c6af0a307..6bceabd7c 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -138,6 +138,13 @@ pub trait PlatformBackend: Send + Sync { pub trait PlatformGeo: Send + Sync { /// Look up geographic information for the given client IP address. /// + /// An implementation must return [`GeoInfo`] with the country as an + /// ISO 3166-1 alpha-2 code (for example `US`) and the region as the + /// ISO 3166-2 subdivision code without the country prefix (for example + /// `CA`). The permission model keys its country and region rules on these + /// codes, matched case-insensitively, so the Fastly and other geo + /// providers feed the same rules without translation. + /// /// # Errors /// /// Returns [`PlatformError::Geo`] when the platform geo lookup fails diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a6c535ba9..1c34b0f14 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -10,6 +10,7 @@ use super::{ PlatformSecretStore, }; use crate::ec::provider::EdgeCookieProvider; +use crate::evidence::HostSignals; /// Geographic information extracted from a request. /// @@ -190,11 +191,15 @@ pub struct RuntimeServices { pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// Host-computed client fingerprints (TLS JA4, HTTP/2), when the host + /// supplies them. `None` on a host that exposes none, so a provider that + /// requires them cannot be built and the request stops. + pub(crate) host_signals: Option>, /// 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)). + /// own crate and be injected, so core never names a vendor (the same pattern + /// as [`geo`](Self::geo) and [`host_signals`](Self::host_signals)). pub(crate) ec_provider: Option>, } @@ -283,6 +288,18 @@ impl RuntimeServices { &self.client_info } + /// Returns the host-computed client fingerprints, when the host supplies + /// them. + /// + /// A provider that derives identity from the TLS JA4 or HTTP/2 fingerprints + /// takes these as an injected service. The result is `None` on a host that + /// exposes none, so such a provider cannot be built there and the request + /// stops rather than minting a degraded identifier. + #[must_use] + pub fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + /// Returns the adapter-injected Edge Cookie provider, when one is wired. /// /// `None` when the deployment uses only the built-in providers (which core @@ -361,6 +378,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + host_signals: Option>, ec_provider: Option>, } @@ -377,6 +395,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + host_signals: None, ec_provider: None, } } @@ -457,6 +476,17 @@ impl RuntimeServicesBuilder { self } + /// Set the host-computed client fingerprints service. + /// + /// Optional: a host that exposes no TLS/HTTP-2 fingerprints leaves this + /// unset, so a provider that requires them cannot be built and the request + /// stops. + #[must_use] + pub fn host_signals(mut self, host_signals: Arc) -> Self { + self.host_signals = Some(host_signals); + self + } + /// Set the adapter-injected Edge Cookie provider. /// /// Optional: leave it unset for a deployment that uses only the built-in @@ -509,6 +539,7 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + host_signals: self.host_signals, ec_provider: self.ec_provider, } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index fde31f749..a5838163d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -586,6 +586,7 @@ impl Ec { let configured = match key { "hmac" => self.providers.hmac.is_some(), + "host-signals" => self.providers.host_signals.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. @@ -678,6 +679,13 @@ pub struct EcProviders { #[validate(nested)] pub hmac: Option, + /// The built-in host-signal provider, keyed `host-signals`. Mints the Edge + /// Cookie from the host's TLS/HTTP-2 fingerprints plus the client IP, so it + /// requires a host that supplies those fingerprints. + #[serde(default, rename = "host-signals")] + #[validate(nested)] + pub host_signals: 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 @@ -715,7 +723,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.hmac.is_none() && self.host_signals.is_none() && self.vendor.is_empty() } } @@ -723,12 +731,118 @@ impl EcProviders { /// /// Mapped from the `[ec.providers.hmac]` TOML block. #[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))] pub passphrase: Redacted, } +/// Configuration for the built-in host-signal Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.host-signals]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HostSignalsProviderConfig { + /// Passphrase used as the HMAC key over the host fingerprints and client IP. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Device-detection configuration. +/// +/// Mapped from the `[device]` TOML section. Selects which device-detection +/// provider classifies a request into device signals, mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct DeviceConfig { + /// The key of the device-detection provider to activate. + /// + /// Defaults to the built-in `builtin` provider when absent, which classifies + /// from the User-Agent alone and makes no host-specific call, so the default + /// path stays host-neutral. The opt-in `fastly` provider strengthens the + /// browser/bot gate with the host's TLS/H2 fingerprints. Override it with the + /// `TRUSTED_SERVER__device__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. An unknown key is + /// rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, +} + +impl DeviceConfig { + /// Returns the active device-detection provider key, defaulting to the + /// built-in heuristic. + #[must_use] + pub fn provider_key(&self) -> &str { + self.provider.as_deref().unwrap_or("builtin") + } + + /// Validates that the selected device-detection provider is available in + /// this build. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + match self.provider_key() { + "builtin" | "fastly" => Ok(()), + key => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Device detection provider `{key}` is not available in this build" + ), + })), + } + } +} + +/// Geo / IP intelligence configuration. +/// +/// Mapped from the `[geo]` TOML section. Selects which provider resolves a +/// client IP into [`GeoInfo`](crate::platform::GeoInfo), mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GeoConfig { + /// The key of the geo provider to activate. + /// + /// The host platform's geo lookup is the default when absent, matching the + /// behavior before this selector existed; `provider = "platform"` spells + /// the same choice explicitly. Selecting `provider = "none"` resolves no + /// geolocation and makes no host geo call, so a deployment can opt out of + /// any host geo service. Override it with the + /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled + /// WebAssembly can switch providers at deployment. An unknown key is rejected + /// at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, +} + +impl GeoConfig { + /// Validates that the selected geo provider is available in this build. + /// + /// No selector is valid and is the default, selecting the host platform's + /// geo lookup. The explicit `"none"` runs without geolocation, the same + /// way the Edge Cookie provider runs statelessly when `"none"` is + /// selected. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + match self.provider.as_deref() { + None | Some("platform") | Some("none") => Ok(()), + Some(key) => Err(Report::new(TrustedServerError::Configuration { + message: format!("Geo provider `{key}` is not available in this build"), + })), + } + } +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Rewrite { @@ -2847,6 +2961,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default)] + #[validate(nested)] + pub device: DeviceConfig, + #[serde(default)] + #[validate(nested)] + pub geo: GeoConfig, } impl Settings { @@ -2936,6 +3056,8 @@ impl Settings { settings.ec.migrate_legacy_passphrase()?; settings.ec.validate_provider_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3031,6 +3153,11 @@ impl Settings { { insecure_fields.push("ec.providers.hmac.passphrase".to_owned()); } + 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()); + } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); } @@ -4160,7 +4287,11 @@ mod tests { // 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 toml_str = crate_test_settings_str().replace( + "provider = \"hmac\" +", + "", + ); let err = Settings::from_toml(&toml_str) .expect_err("a provider block with no selector should fail at startup"); @@ -4441,6 +4572,34 @@ mod tests { ); } + #[test] + fn device_provider_defaults_to_builtin_and_rejects_unknown() { + let config = DeviceConfig::default(); + assert_eq!( + config.provider_key(), + "builtin", + "no selector should default to the built-in provider" + ); + config + .validate_provider_selection() + .expect("should validate the built-in default"); + + let fastly = DeviceConfig { + provider: Some("fastly".to_owned()), + }; + fastly + .validate_provider_selection() + .expect("should validate the fastly opt-in"); + + let unknown = DeviceConfig { + provider: Some("acme".to_owned()), + }; + assert!( + unknown.validate_provider_selection().is_err(), + "an unknown device provider should be rejected at startup" + ); + } + #[test] fn an_unselected_provider_block_is_rejected() { // A vendor selector with the vendor block present, plus a stray hmac @@ -4461,6 +4620,67 @@ mod tests { ); } + #[test] + fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.provider.is_none(), + "geo should default to no selector, which selects the host geo" + ); + config + .validate_provider_selection() + .expect("should validate the default host geo selection"); + + let platform = GeoConfig { + provider: Some("platform".to_owned()), + }; + platform + .validate_provider_selection() + .expect("should validate the explicit platform selection"); + + let none = GeoConfig { + provider: Some("none".to_owned()), + }; + none.validate_provider_selection() + .expect("should validate the explicit opt-out of geolocation"); + + let unknown = GeoConfig { + provider: Some("acme".to_owned()), + }; + assert!( + unknown.validate_provider_selection().is_err(), + "an unknown geo provider should be rejected at startup" + ); + } + + #[test] + fn unknown_keys_in_provider_sections_are_rejected() { + // A mistyped key must fail at startup rather than silently selecting + // a default behind the operator's back. + for (section, bad_key) in [ + ("[geo]", "providr = \"platform\""), + ("[device]", "providr = \"builtin\""), + ] { + let toml_str = format!( + "{}\n\n {section}\n {bad_key}\n", + crate_test_settings_str() + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in {section} should be rejected" + ); + } + + let toml_str = crate_test_settings_str().replace( + "[ec.providers.hmac]", + "[ec.providers.hmac]\n unexpected = \"value\"", + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in [ec.providers.hmac] should be rejected" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -5407,6 +5627,7 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -5443,9 +5664,10 @@ origin_host_header_overide = "www.example.com""#, passphrase = "test-secret-key-32-bytes-minimum" "#, ); + let error = result.expect_err("should reject a zero buffered-body cap"); assert!( - result.is_err(), - "publisher.max_buffered_body_bytes = 0 must fail config validation" + error.to_string().contains("max_buffered_body_bytes"), + "the rejection should be for the zero cap, not another validation, got: {error}" ); } 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 fa6fef6e8..497205d4c 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 @@ -9,6 +9,12 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" +# The lifecycle scenarios need a resolved jurisdiction for the consent gate, +# and Viceroy supplies the host geo lookup. The permission model replaces this +# with a [geo] default_country baseline in the next PR of the series. +[geo] +provider = "platform" + [ec] provider = "hmac" ec_store = "ec_identity_store" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index ccd062090..f6a8226cb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -11,6 +11,18 @@ origin_url = "https://origin.example.com" # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Device detection provider selection. The built-in provider classifies from +# the User-Agent alone and makes no host-specific call. The opt-in "fastly" +# provider strengthens the browser/bot gate with the host's TLS/H2 signals. +# [device] +# provider = "builtin" + +# Geo / IP intelligence provider selection. The platform host geo is the +# default. Set provider = "none" to resolve no location and make no host geo +# call. +# [geo] +# provider = "platform" + [ec] # Edge Cookie identity is OFF by default: with no provider selected, Trusted # Server runs statelessly and generates no Edge Cookie. Activate one by From 43e7ddaa2e6389cb87ea9895efc31c09d15c9b52 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 14:20:02 +0100 Subject: [PATCH 3/3] Add the permission model with the Privacy Taxonomy vocabulary Third slice of the PR 838 decomposition. Permissions become the primitive that gates identity features; consent is one of several ways a permission is established: - permissions.rs resolves a per-request PermissionState from the country/region baseline in permissions.yaml augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the Privacy Taxonomy Data Uses. - Signal precedence is fixed in code, most restrictive first: a US-style opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses the policy revokes even when a TCF record consents, a present but undecodable record blocks baseline grants (fail-closed), and only then does a TCF record decide its mapped Data Uses. The yaml authoritative flag governs the TCF record's own grants and revokes, never whether an opt-out can be overridden. Pinning tests cover each opt-out source against a consenting TCF record. - Destructive withdrawal is narrow: only a TCF record refusing storage in a jurisdiction whose baseline did not grant it expires the cookie and writes the identity-graph tombstone. Opt-outs suppress use (headers stripped, nothing egressed) but never destroy an issued identifier, so lifting an opt-out restores the identity. - Sharing beyond the edge requires storage plus personalised-ad selection, the same pair that gates bidstream EIDs, at every egress: the auction endpoint's user.id, the publisher navigation and page-bids auction requests, the identify response, and partner pull-sync. A storage-only grant keeps first-party use while withholding partner sharing. - The Edge Cookie gate moves from raw consent to the permission model: a provider declares required_permissions() and core executes it only when every one is set. - [geo] default_country becomes required: it names the permissions.yaml rule that applies when the geo provider leaves a request unmatched. A failed geo lookup is distinct: it resolves at the requires-signal floor instead of the default, and is logged at error level. The lookup moves into EcContext::read_from_request_resolving_geo so every adapter reports the distinction identically. - Geolocation is now off by default ([geo] provider unset resolves no location); the host lookup is opt-in via provider = "platform". A deployment that runs an Edge Cookie provider with no geo provider must set [geo] assume_single_jurisdiction = true, acknowledging that every request is treated as the default jurisdiction. - permissions.yaml rules use an explicit per-permission acquisition map (granted / requires_signal / denied) instead of +/- sigils, unknown keys in a detailed rule are rejected, and two rule keys naming the same location in different case are rejected at parse. - The consent module keeps building the ConsentContext; its EC-specific gating helpers move behind the permission model. An EU-27 plus EEA coverage test locks the gdpr-eu mapping. - The design spec for this slice lives at docs/superpowers/specs/2026-07-30-permission-model-design.md, the 2026-07-31 draft revised to match this implementation with a revision-record table of every divergence. --- CLAUDE.md | 81 +- Cargo.lock | 20 + Cargo.toml | 1 + crates/trusted-server-adapter-axum/src/app.rs | 16 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 4 + .../src/app.rs | 16 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 8 + .../trusted-server-adapter-fastly/src/app.rs | 54 +- .../trusted-server-adapter-fastly/src/main.rs | 8 + .../src/middleware.rs | 4 + crates/trusted-server-adapter-spin/src/app.rs | 16 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 4 + crates/trusted-server-core/Cargo.toml | 1 + .../src/auction/endpoints.rs | 51 +- crates/trusted-server-core/src/config.rs | 4 + crates/trusted-server-core/src/consent/mod.rs | 669 ++------ .../trusted-server-core/src/consent/types.rs | 15 + crates/trusted-server-core/src/ec/consent.rs | 632 ++++++- crates/trusted-server-core/src/ec/device.rs | 18 + crates/trusted-server-core/src/ec/finalize.rs | 81 +- crates/trusted-server-core/src/ec/identify.rs | 6 +- crates/trusted-server-core/src/ec/mod.rs | 231 ++- crates/trusted-server-core/src/ec/provider.rs | 167 +- .../trusted-server-core/src/ec/pull_sync.rs | 7 +- crates/trusted-server-core/src/edge_cookie.rs | 5 +- .../src/integrations/google_tag_manager.rs | 8 + .../src/integrations/prebid.rs | 9 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/permissions.rs | 1495 +++++++++++++++++ .../trusted-server-core/src/platform/mod.rs | 56 +- .../src/platform/traits.rs | 8 + crates/trusted-server-core/src/publisher.rs | 23 +- .../src/response_privacy.rs | 4 + crates/trusted-server-core/src/settings.rs | 249 ++- .../trusted-server-core/src/test_support.rs | 9 + .../configs/trusted-server.integration.toml | 14 +- .../tests/parity.rs | 4 + docs/guide/permission-model.md | 303 ++++ .../2026-07-30-permission-model-design.md | 807 +++++++++ permissions.yaml | 345 ++++ trusted-server.example.toml | 21 +- 44 files changed, 4686 insertions(+), 801 deletions(-) create mode 100644 crates/trusted-server-core/src/permissions.rs create mode 100644 docs/guide/permission-model.md create mode 100644 docs/superpowers/specs/2026-07-30-permission-model-design.md create mode 100644 permissions.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..53b50a6d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,10 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC default is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -58,7 +62,9 @@ fastly compute serve # Deploy to Fastly fastly compute publish -# Run Axum dev server (native — no Viceroy) +# Run Axum dev server (native — no Viceroy). Settings load at runtime from the +# platform config store on every adapter; publish an operator config with +# `ts config push` (see trusted-server.example.toml for the template). cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -142,6 +148,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.17.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +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`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +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. + --- ## Coding Conventions @@ -269,12 +289,34 @@ impl core::error::Error for MyError {} ## Other guidelines +- Use US English spelling everywhere: code, identifiers, comments, + documentation, tests, commit messages, and configuration. For example, write + `color`, `behavior`, and `optimize`, not `colour`, `behaviour`, or `optimise`. + Where a term comes from an external source (for example the IAB TCF purpose + names), match that source's spelling even when it is not US English. - Use only example or fictional information in comments, tests, docs, examples, and similar non-runtime materials. (eg. for urls use: example.com domains only) - Do not write or commit real domains, customer names, credentials, configuration values, or other potentially sensitive real-world information in comments, tests, docs, or examples. +### Permission model terminology + +Permissions are the primitive. A provider declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +is only one of several ways a permission may be established. Country or +jurisdiction rules (a `Granted` group baseline), legitimate interest, or +configuration can set a permission with no consent at all. + +- A provider that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated provider **runs once its required permissions are set**, by whatever + method. +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -291,6 +333,41 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- | +| 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/` | + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 fingerprints) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no + host fingerprint calls. +- **Providers are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie provider + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + several vendor providers share a backend (for example a vendor's Edge Cookie, + geo, and device provider on one cloud pipeline) they share a single call per + request rather than calling independently. Give a provider the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -324,7 +401,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 Trusted Server app-config template | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and `[geo] default_country`) | | `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 b186502f8..cf55eed9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4567,6 +4567,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5477,6 +5490,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5648,6 +5662,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 00d3d732d..2bb4b91c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" sha2 = "0.10.9" simple_logger = "5" spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index d125c9cd5..b82561ce2 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -160,18 +160,10 @@ where /// 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 { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - 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() - }) + EcContext::read_from_request_resolving_geo(&state.settings, req, services).unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 0009e1953..8289563af 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -139,6 +139,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 5de96be92..093268568 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -37,6 +37,10 @@ fn test_router() -> edgezero_core::router::RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 02cbbe724..fc8d7d24e 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -140,18 +140,10 @@ fn build_per_request_services(ctx: &RequestContext, settings: &Settings) -> Runt /// 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 { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - 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() - }) + EcContext::read_from_request_resolving_geo(settings, req, services).unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index cb22e2126..d857b68dd 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -155,6 +155,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 93b0f9db9..a678c6683 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -40,6 +40,10 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -87,6 +91,10 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1b5777f78..4585f9155 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -108,7 +108,6 @@ use trusted_server_core::ec::admin::{ deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup, }; use trusted_server_core::ec::batch_sync::handle_batch_sync; -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; @@ -435,34 +434,29 @@ fn build_ec_request_state( let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during EC setup: {e}"); - None - }); - let (ec_context, setup_error) = - match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + match EcContext::read_from_request_resolving_geo(settings, req, services) { Ok(mut context) => { context.set_device_signals(device_signals); (context, None) } Err(report) => (EcContext::default(), Some(report)), }; + let geo_info = ec_context.geo_info().cloned(); // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations keep the write path so tombstones stay - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // stays open for withdrawal so tombstones remain authoritative even for + // privacy-extension-heavy clients that do not look like known browsers. A + // merely not-permitted (pre-consent or fail-closed) request writes nothing, + // so it does not need the graph. let kv_graph = crate::maybe_identity_graph(settings); - let finalize_kv_graph = if setup_error.is_none() - && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) - { - kv_graph.clone() - } else { - None - }; + let finalize_kv_graph = + if setup_error.is_none() && (is_real_browser || ec_context.storage_withdrawn()) { + kv_graph.clone() + } else { + None + }; let kv_graph = if is_real_browser { kv_graph } else { None }; EcRequestState { @@ -1276,7 +1270,7 @@ impl TrustedServerApp { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), - Arc::new(FastlyPlatformGeo), + build_geo_provider(&state.settings, Arc::new(FastlyPlatformGeo)), )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); @@ -1383,6 +1377,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1455,6 +1453,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1889,6 +1891,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse production-shaped settings"); @@ -2532,6 +2538,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -2660,6 +2670,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d9b877176..4e527a153 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -580,6 +580,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -710,6 +714,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] passphrase = "test-secret-key-32-bytes-minimum" diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 7702386df..f7c2e401c 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -299,6 +299,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ed45bed90..c0e255374 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -342,18 +342,10 @@ fn health_response() -> Response { /// falls back to the default (fail-closed) context rather than being silently /// swallowed. fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - 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() - }) + EcContext::read_from_request_resolving_geo(settings, req, services).unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) } fn admin_key_management_not_supported() -> Response { diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 3cadf721d..ad151e111 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -182,6 +182,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2389ccebc..ce32424c4 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -39,6 +39,10 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e44d46f77..afff4c696 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -40,6 +40,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } toml = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index fdf387e93..86bd7b981 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -171,8 +171,9 @@ pub async fn handle_auction( // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. - // Only forward the EC ID to auction partners when consent allows it. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted: + // storage plus personalised-ad selection, the same pair that gates EIDs. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value() } else { None @@ -237,8 +238,9 @@ pub async fn handle_auction( // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. // - // Gate this on the same identity-consent condition as the EC ID - // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Gate this on the same identity condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by the sharing pair via + // `ec_context.ec_sharing_allowed()`). // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` @@ -282,10 +284,9 @@ pub async fn handle_auction( // consent gating before attaching them to the auction request. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); - auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + auction_request.user.eids = gate_eids_by_permissions(merged_eids, ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { - log::warn!("Auction EIDs stripped by TCF consent gating"); + log::warn!("Auction EIDs stripped: bidstream permissions not set"); } // Create auction context @@ -616,13 +617,11 @@ mod tests { .build() } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), - ConsentContext { - jurisdiction, - ..ConsentContext::default() - }, + ConsentContext::default(), + ec_allowed, ) } @@ -744,7 +743,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -798,7 +801,9 @@ mod tests { let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + // The default consent context keeps the jurisdiction unknown, so the + // server-side auction gate fails closed; the EC gate is off to match. + let ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -995,7 +1000,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_kv() { let registry = PartnerRegistry::empty(); 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 result = resolve_auction_eids(None, Some(®istry), &ec_context); assert!(result.is_none(), "should return None when KV is missing"); @@ -1005,7 +1010,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_registry() { let kv = KvIdentityGraph::failing("test_store"); 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 result = resolve_auction_eids(Some(&kv), None, &ec_context); assert!( @@ -1019,7 +1024,7 @@ mod tests { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1032,7 +1037,11 @@ mod tests { fn resolve_auction_eids_returns_none_when_no_ec() { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1046,7 +1055,7 @@ mod tests { let kv = KvIdentityGraph::failing("nonexistent_store"); let registry = PartnerRegistry::empty(); 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)); // KV store doesn't exist, so the get() call will error — should return // empty Vec (degraded mode), not None. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 75f22b864..0c4670137 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -364,6 +364,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +default_country = "FR" +assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..ab375f070 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -54,6 +54,7 @@ use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::permissions::{Permission, PermissionState}; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -322,7 +323,7 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b /// Returns the effective decoded TCF consent for enforcement decisions. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub(crate) 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() @@ -475,46 +476,38 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( +pub fn gate_eids_by_permissions( eids: Option>, - consent_ctx: Option<&ConsentContext>, + permissions: &PermissionState, ) -> Option> { let eids = eids?; if eids.is_empty() { return None; } - let tcf = consent_ctx.and_then(effective_tcf); - - match tcf { - Some(tcf) if allows_eid_transmission(tcf) => Some(eids), - Some(_) => { - log::info!("EIDs stripped: TCF Purpose 1 or 4 consent missing"); - None - } - None => { - // No TCF data — if GDPR applies, block EIDs as a precaution. - if consent_ctx.is_some_and(|c| c.gdpr_applies) { - log::info!("EIDs stripped: GDPR applies but no TCF consent available"); - None - } else { - Some(eids) - } - } + if permissions.is_set(Permission::StoreOnDevice) + && permissions.is_set(Permission::SelectPersonalisedAds) + { + Some(eids) + } else { + log::info!( + "EIDs stripped: necessary.operations.storage or advertising_marketing.first_party.targeted is not set in the resolved permissions" + ); + None } } @@ -522,110 +515,30 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- -/// Determines whether Edge Cookie (EC) creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Returns `true` when the request carries a US-style storage/sale opt-out +/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of +/// jurisdiction. /// -/// The decision follows the jurisdiction's consent model: +/// This reports the signal only. Whether the opt-out changes a permission is +/// decided by the country/region map when the permission state is assembled: it +/// drops a `granted` baseline (for example a US opt-out state) and has nothing to +/// drop where the permission is `requires_signal`. Honoring it everywhere is +/// intentionally conservative. /// -/// - **GDPR (EU/UK)**: opt-in required — TCF Purpose 1 (store/access -/// information on a device) must be explicitly consented. If no TCF data is -/// available under GDPR, consent is assumed absent and EC is blocked. -/// - **US state privacy**: opt-out model — EC is allowed unless the user has -/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or -/// the US Privacy string. Explicit US opt-out signals take precedence over -/// TCF storage consent. -/// - **Non-regulated**: EC is allowed (no consent requirement). -/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is -/// blocked as a precaution. +/// TCF consent or refusal is handled separately by +/// [`crate::ec::consent::permission_signal`], which treats a present TCF record +/// as authoritative, so this helper does not consider TCF. #[must_use] -pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of other consent signals. - if ctx.gpc { - return false; - } - // Explicit US opt-out signals take precedence over TCF storage - // consent in US-state jurisdictions. - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return false; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the TCF - // Purpose 1 decision if no explicit US opt-out signal is present. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // GPP US sale_opt_out=false is an explicit non-opt-out signal. - if let Some(gpp) = &ctx.gpp - && let Some(opted_out) = gpp.us_sale_opt_out - { - return !opted_out; - } - // Check US Privacy string when no TCF decision is present. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, +pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { + if ctx.gpc { + return true; } -} - -/// Returns `true` only when the request contains an explicit EC opt-out signal. -/// -/// This is intentionally narrower than [`allows_ec_creation`]. Some requests -/// fail closed because consent cannot be verified yet (for example, missing geo -/// or missing/undecodable consent signals in a regulated jurisdiction). Those -/// cases must block *new* EC creation, but they must not be treated as an -/// authoritative withdrawal of an already-issued EC. -#[must_use] -pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent()) - } - jurisdiction::Jurisdiction::UsState(_) => { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return true; - } - if let Some(tcf) = effective_tcf(ctx) { - return !tcf.has_storage_consent(); - } - false - } - jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false, + if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { + return true; } + ctx.us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) } // --------------------------------------------------------------------------- @@ -701,9 +614,9 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, apply_expiration_check, - apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + ConsentPipelineInput, apply_expiration_check, apply_tcf_conflict_resolution, + build_consent_context, build_context_from_signals, consent_allows_server_side_auction, + gate_eids_by_permissions, has_storage_optout_signal, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -894,7 +807,7 @@ mod tests { } #[test] - fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { + fn missing_geo_keeps_unknown_jurisdiction() { let req = build_request(); let config = ConsentConfig::default(); @@ -912,10 +825,6 @@ mod tests { Jurisdiction::Unknown, "missing geo should keep jurisdiction unknown" ); - assert!( - !allows_ec_creation(&ctx), - "missing geo should keep EC creation fail-closed" - ); } #[test] @@ -1071,408 +980,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // allows_ec_creation tests - // ----------------------------------------------------------------------- - - /// Helper: builds a TCF consent with configurable Purpose 1 (storage). - fn make_tcf_with_storage(has_storage: bool) -> TcfConsent { - TcfBuilder::new().with_storage(has_storage).build() - } - - #[test] - fn ec_allowed_gdpr_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(true)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 consented should allow EC" - ); - } - - #[test] - fn ec_blocked_gdpr_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(false)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 not consented should block EC" - ); - } - - #[test] - fn ec_blocked_gdpr_no_tcf_data() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: None, - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR with no TCF data should block EC" - ); - } - - #[test] - fn ec_allowed_gdpr_via_gpp_embedded_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf_with_storage(true)), - us_sale_opt_out: None, - }), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + GPP embedded TCF with P1 consent should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_no_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + no opt-out should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + opt-out should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_implies_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPC=true with no US Privacy string should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_no_signals() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: false, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + no consent signals should block EC (spec \u{a7}6.1.1: fail-closed)" - ); - } - - #[test] - fn ec_allowed_non_regulated() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "non-regulated jurisdiction should always allow EC" - ); - } - - #[test] - fn ec_blocked_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "unknown jurisdiction should block EC (fail-closed when geo unavailable)" - ); - assert!( - !has_explicit_ec_withdrawal(&ctx), - "unknown jurisdiction should not be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC=true should block EC even when US Privacy says no opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPC=true should be treated as an explicit withdrawal signal" - ); - } - - #[test] - fn ec_us_privacy_not_applicable_allows_ec() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("VA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::NotApplicable, - opt_out_sale: PrivacyFlag::NotApplicable, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US Privacy with opt_out=N/A should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_tcf_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)" - ); - } - - #[test] - fn ec_blocked_us_state_tcf_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(false)), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + TCF Purpose 1 denied should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" - ); - } - - #[test] - fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_allowed_us_state_gpp_no_sale_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=false should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpp_sale_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpc: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when GPP US says no opt-out" - ); - } - - #[test] - fn ec_us_state_gpp_us_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPP US opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: None, - us_sale_opt_out: None, - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GPP without US section should fall through to us_privacy" - ); - } - // ----------------------------------------------------------------------- // Consent KV read-fallback / write-on-change pipeline tests // ----------------------------------------------------------------------- @@ -1629,4 +1136,86 @@ mod tests { "should not persist consent without an EC ID" ); } + + #[test] + fn gate_eids_keeps_eids_when_required_permissions_are_set() { + // US maps to us-opt-out, where necessary.operations.storage and advertising_marketing.first_party.targeted + // are granted with no signal, so bidstream EIDs are transmitted. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_some(), + "EIDs should pass when necessary.operations.storage and advertising_marketing.first_party.targeted are set" + ); + } + + #[test] + fn gate_eids_strips_eids_when_a_required_permission_is_unset() { + // FR maps to gdpr-eu, where every purpose is requires_signal, so with no + // signal neither required permission is set and EIDs are stripped. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("FR"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" + ); + } + + #[test] + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + assert!( + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" + ); + assert!( + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" + ); + } + + #[test] + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); + + let gpp_sale_opt_out = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![8], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" + ); + + let usp_opt_out = ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::No, + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage opt-out" + ); + + assert!( + !has_storage_optout_signal(&ConsentContext::default()), + "no signal is not a storage opt-out" + ); + } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index 73c2bbc3f..2cf8a6a8e 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -149,6 +149,21 @@ pub struct ConsentContext { } impl ConsentContext { + /// Whether any consent record is present in raw form but failed to decode. + /// + /// A malformed record is not the same as no record: the visitor expressed + /// a preference that could not be read, so the permission mapping blocks + /// baseline grants (fail-closed) instead of degrading to the no-signal + /// baseline. An expired TCF record is excluded because expiry is its own + /// explicit state ([`expired`](Self::expired)): the raw string is kept for + /// proxy forwarding while the decoded record is deliberately cleared. + #[must_use] + pub fn has_malformed_record(&self) -> bool { + (self.raw_tc_string.is_some() && self.tcf.is_none() && !self.expired) + || (self.raw_gpp_string.is_some() && self.gpp.is_none()) + || (self.raw_us_privacy.is_some() && self.us_privacy.is_none()) + } + /// Returns `true` when no consent signals are present. #[must_use] pub fn is_empty(&self) -> bool { diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd29..971f31c2c 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,637 @@ -//! EC-specific consent gating. +//! EC-specific permission gating, resolved through the permission model. //! -//! This module provides the public consent-check API for the EC subsystem. -//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this -//! wrapper exists so that EC callers can import from `ec::consent` and the -//! eventual migration path (renaming, adding EC-specific conditions) is -//! contained here. +//! 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 +//! re-deriving one. use crate::consent::ConsentContext; +use crate::permissions::{ + Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, + SignalPolicy, +}; +use crate::platform::GeoInfo; +use crate::settings::Settings; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// The outcome of the geo lookup for a request, separating "no location +/// resolved" from "the lookup failed". /// -/// This is the canonical entry point for EC consent checks. It delegates -/// to [`crate::consent::allows_ec_creation`] today but may diverge as -/// EC-specific consent rules evolve. +/// The two must not collapse: with no location (the provider is disabled, or +/// had no data for the address) the deployer's `[geo] default_country` +/// baseline applies, but when the lookup errored the request's location is +/// unknown in a way the deployer default must not paper over, so every +/// permission resolves to the requires-signal floor instead. +#[derive(Debug, Clone, Copy)] +pub enum GeoStatus<'a> { + /// The provider resolved a location. + Located(&'a GeoInfo), + /// The provider resolved no location, so the configured default applies. + NoLocation, + /// The lookup errored, so the requires-signal floor applies. + Failed, +} + +impl<'a> GeoStatus<'a> { + /// The resolved location, when one exists. + #[must_use] + pub fn info(self) -> Option<&'a GeoInfo> { + match self { + GeoStatus::Located(info) => Some(info), + GeoStatus::NoLocation | GeoStatus::Failed => None, + } + } +} + +impl<'a> From> for GeoStatus<'a> { + fn from(geo: Option<&'a GeoInfo>) -> Self { + match geo { + Some(info) => GeoStatus::Located(info), + None => GeoStatus::NoLocation, + } + } +} + +/// Splits the configured `[geo] default_country` into its country and region +/// parts (`US/CA` names a country and region, `US` a bare country). +fn default_location(settings: &Settings) -> (Option<&str>, Option<&str>) { + match settings.geo.default_country.as_deref() { + Some(spec) => match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }, + None => (None, None), + } +} + +/// Assembles the permission state for a request: the country/region baseline +/// from the default maps in `permissions.yaml`, augmented by the session's +/// signals. +/// +/// 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 +/// deployer's configured `[geo] default_country` applies. A default is required, +/// so it 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 deployer's default jurisdiction. +#[must_use] +pub fn assemble_permissions( + settings: &Settings, + consent: &ConsentContext, + geo: GeoStatus<'_>, +) -> PermissionState { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = match geo { + GeoStatus::Failed => (None, None), + GeoStatus::Located(_) | GeoStatus::NoLocation => default_location(settings), + }; + let info = geo.info(); + maps.resolve_with( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + default_country, + default_region, + permission_signal(consent, maps.signals()), + ) +} + +/// The acquisition rule for Edge Cookie storage in the request's resolved +/// jurisdiction, used to scope destructive withdrawal. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// Resolves the same rules as [`assemble_permissions`] (the request's +/// country/region, the configured default when unmatched, and the +/// requires-signal floor when the lookup failed or nothing resolves) and +/// returns the rule for [`Permission::StoreOnDevice`]. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn storage_acquisition(settings: &Settings, geo: GeoStatus<'_>) -> Acquisition { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = match geo { + GeoStatus::Failed => (None, None), + GeoStatus::Located(_) | GeoStatus::NoLocation => default_location(settings), + }; + let info = geo.info(); + maps.rules_or_default( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + default_country, + default_region, + ) + .map_or(Acquisition::RequiresSignal, |rules| { + rules.rule_for(Permission::StoreOnDevice) + }) +} + +/// Maps a consent context 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. +/// +/// 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: +/// +/// 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 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`. +fn permission_signal<'a>( + consent: &'a ConsentContext, + signals: &'a SignalPolicy, +) -> impl Fn(Permission) -> ConsentSignal + 'a { + move |permission| { + if opt_out_present(consent, signals.opt_out_sources()) + && signals.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + if 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 + } + } + None => ConsentSignal::Neutral, + }; + } + ConsentSignal::Neutral + } +} + +/// 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), + }) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. /// -/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed -/// result such as unknown jurisdiction or missing consent data must not be -/// treated as an authoritative withdrawal of an already-issued EC. +/// 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_consent_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +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::jurisdiction::Jurisdiction; + 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], + } + } + + #[test] + fn hmac_provider_is_blocked_without_a_storage_signal() { + // The test settings select the HMAC provider, which requires + // necessary.operations.storage. The configured default country (FR) + // resolves storage as requires-signal, so with no signal the + // permission is not set and the provider's requirement is not met. + let settings = create_test_settings(); + 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(&settings, &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 settings = create_test_settings(); + let geo = us_ca_geo(); + let state = assemble_permissions( + &settings, + &ConsentContext::default(), + GeoStatus::Located(&geo), + ); + assert!( + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "a US opt-out state should grant necessary.operations.storage and advertising_marketing.first_party.targeted" + ); + } + + #[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 settings = create_test_settings(); + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + 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" + ); + } + + // ------------------------------------------------------------------ + // 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. + // ------------------------------------------------------------------ + + #[test] + fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &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" + ); + } + + #[test] + fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + 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, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &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" + ); + } + + #[test] + fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + 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(&settings, &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" + ); + } + + #[test] + fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let settings = create_test_settings(); + 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(&settings, &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 ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, + 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_consent_granted(&ctx), - "non-regulated requests should be allowed" + !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 ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + 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_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), + "absence of a signal must never destroy an identifier" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + 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" + ); + } + + // ------------------------------------------------------------------ + // 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() { + let settings = create_test_settings(); + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &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() + }; + assert!( + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" + ); + } + + #[test] + fn an_expired_tcf_record_is_not_treated_as_malformed() { + let settings = create_test_settings(); + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); + } + + // ------------------------------------------------------------------ + // Geo status: a failed lookup resolves at the requires-signal floor, + // while no location falls back to the configured default. + // ------------------------------------------------------------------ + + #[test] + fn a_failed_geo_lookup_resolves_to_the_requires_signal_floor() { + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let state = assemble_permissions(&settings, &ConsentContext::default(), GeoStatus::Failed); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a lookup failure must not fall back to the deployer default baseline" + ); + assert_eq!( + storage_acquisition(&settings, GeoStatus::Failed), + Acquisition::RequiresSignal, + "the storage baseline follows the same floor on failure" + ); + } + + #[test] + fn no_location_falls_back_to_the_configured_default() { + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let state = + assemble_permissions(&settings, &ConsentContext::default(), GeoStatus::NoLocation); + assert!( + state.is_set(Permission::StoreOnDevice), + "no location should resolve at the configured default baseline" + ); + assert_eq!( + storage_acquisition(&settings, GeoStatus::NoLocation), + Acquisition::Granted, + "the storage baseline follows the default on no location" + ); + } + + #[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 settings = create_test_settings(); + 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(&settings, &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!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + 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/device.rs b/crates/trusted-server-core/src/ec/device.rs index daad89caf..480b9e96b 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -140,6 +140,14 @@ pub trait DeviceProvider: Send + Sync { /// so this is infallible: a provider that cannot determine a signal returns /// the unknown variant rather than failing the request. fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the built-in User-Agent-only provider requires + /// no permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } } /// The built-in device provider, the default. @@ -809,6 +817,16 @@ mod tests { } } + #[test] + fn builtin_device_provider_requires_no_permissions() { + assert!( + BuiltinDeviceProvider::new() + .required_permissions() + .is_empty(), + "the built-in User-Agent-only device provider requires no permissions" + ); + } + #[test] fn build_device_provider_defaults_to_builtin_and_selects_injected() { // The default selector returns the built-in provider, ignoring the diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index d09d8097a..87d6a26f6 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -11,7 +11,6 @@ use http::Response; use crate::settings::Settings; use super::EcContext; -use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::kv::KvIdentityGraph; use super::log_id; @@ -28,7 +27,7 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// Finalizes EC response behavior for all routes. /// -/// Applies the resolved consent gate, last-seen updates, cookie +/// Applies the resolved permission state, last-seen updates, cookie /// reconciliation, Prebid EID ingestion, and cookie writes for new EC generation. /// /// When the request carries an explicit withdrawal signal (a storage opt-out or @@ -67,10 +66,10 @@ pub fn ec_finalize_response( // Only expire the browser cookie and tombstone the identity-graph row // when the request carries an explicit withdrawal signal. A pre-consent - // or fail-closed state (consent is simply not granted) strips headers + // or fail-closed state (the permission is simply not set) 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() { + if ec_context.storage_withdrawn() && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -420,9 +419,12 @@ 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. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; @@ -473,6 +475,65 @@ mod tests { ); } + /// A decoded TCF record refusing every purpose, storage included. + fn refusing_tcf() -> crate::consent::TcfConsent { + 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], + } + } + + #[test] + fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { + // A US-style opt-out suppresses use (headers cleared, nothing egressed) + // but is never destructive: the browser cookie is not expired, so a + // visitor who later withdraws the opt-out keeps their identity. + let settings = create_test_settings(); + let ec_id = sample_ec_id("aBc123"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = + 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"); + + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + None, + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "x-ts-ec").is_none(), + "the opt-out should clear the EC header" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "the opt-out should not expire the browser cookie" + ); + } + #[test] fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); @@ -679,10 +740,10 @@ mod tests { } #[test] - fn closed_consent_gate_writes_no_ec_cookie() { - // The gate: with the consent gate closed (ec_allowed = false), no + fn closed_permission_gate_writes_no_ec_cookie() { + // The gate: with the permission 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. + // present. The permission model is what suppresses the cookie. let settings = create_test_settings(); let ec_id = sample_ec_id("gated1"); let ec_context = make_context( @@ -711,7 +772,7 @@ mod tests { assert!( get_header(&response, "set-cookie").is_none(), - "a closed consent gate must not write a ts-ec cookie" + "a closed permission gate must not write a ts-ec cookie" ); } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index eeadaa290..eaec9f5f8 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -61,7 +61,11 @@ pub fn handle_identify( ); }; - if !ec_context.ec_allowed() { + // Identify returns the partner's UID for this visitor, which is sharing + // the identity beyond the edge, so it needs the same permission pair as + // bidstream EIDs (storage plus personalised-ad selection), not only the + // provider's storage permission. + if !ec_context.ec_sharing_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index d1f774b66..fbf9b1dc8 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -15,7 +15,7 @@ //! //! - auth (private) — shared Bearer-token authentication helpers //! - [`generation`] — HMAC-based ID generation, IP normalization, format helpers -//! - [`consent`] — EC-specific consent gating wrapper +//! - [`consent`]: EC-specific permission gating, with consent as one input //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers //! - [`kv`] — KV Store identity graph operations (CAS, tombstones, debounce) //! - [`kv_backend`] — Platform-neutral KV primitives implemented by adapters @@ -75,6 +75,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::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -165,10 +166,19 @@ 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). + /// Whether the configured Edge Cookie provider's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// 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, @@ -236,11 +246,67 @@ impl EcContext { services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { + Self::read_from_request_with_geo_status( + settings, + req, + services, + consent::GeoStatus::from(geo_info), + ) + } + + /// Reads the EC context, resolving the location through the configured geo + /// provider first. + /// + /// This is the constructor adapters use: it runs the geo lookup itself so + /// a failed lookup is distinguished from "no location resolved". No + /// location falls back to the configured `[geo] default_country` + /// baseline, while a failure resolves every permission to the + /// requires-signal floor (see [`consent::GeoStatus`]) and is logged at + /// error level so an outage is visible. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the selected Edge Cookie provider + /// cannot be built, the same as + /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). + pub fn read_from_request_resolving_geo( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + ) -> Result> { + let lookup = services.geo().lookup(services.client_info().client_ip); + let geo_info = match &lookup { + Ok(info) => info.clone(), + Err(error) => { + log::error!( + "geo lookup failed; resolving permissions at the requires-signal floor: {error:?}" + ); + None + } + }; + let status = match (&lookup, &geo_info) { + (Err(_), _) => consent::GeoStatus::Failed, + (Ok(_), Some(info)) => consent::GeoStatus::Located(info), + (Ok(_), None) => consent::GeoStatus::NoLocation, + }; + Self::read_from_request_with_geo_status(settings, req, services, status) + } + + fn read_from_request_with_geo_status( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo_status: consent::GeoStatus<'_>, + ) -> Result> { + let geo_info = geo_status.info(); 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. + // Build the selected provider once, injecting any host signals the host + // supplies. It is used here to decide whether the incoming cookie value + // is a usable identifier and to read its required permissions. A provider + // that needs a service the host did not supply fails to build here, which + // stops the request. Reading either needs no request data, so nothing is + // cloned from the request. let host_signals = services.host_signals(); let ec_provider = services.ec_provider(); let selected_provider: Option> = @@ -297,14 +363,18 @@ 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. + // 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 + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let permissions = consent::assemble_permissions(settings, &consent, geo_status); + let storage_acquisition = consent::storage_acquisition(settings, geo_status); + // With no provider selected nothing may mint or use an identifier, so + // the gate is closed rather than open by default. let ec_allowed = selected_provider .as_ref() - .is_some_and(|_| consent::ec_consent_granted(&consent)); + .is_some_and(|selected| permissions.all_set(selected.required_permissions())); log::info!( "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", @@ -321,6 +391,8 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, + storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -364,7 +436,7 @@ impl EcContext { if !self.ec_allowed { log::info!( - "EC generation skipped: EC creation not permitted (jurisdiction={})", + "EC generation skipped: required permissions not set (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); @@ -392,7 +464,7 @@ impl EcContext { /// 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) + /// only the client IP. The skip guards (existing EC, permission gate) /// stay in [`generate_if_needed`](Self::generate_if_needed). /// /// # Errors @@ -407,6 +479,7 @@ impl EcContext { kv: Option<&KvIdentityGraph>, ) -> Result<(), Report> { let input = IdentityInput { + permissions: Some(&self.permissions), consent: Some(&self.consent), }; // Pass the request evidence captured at read time, borrowed: the client @@ -541,8 +614,9 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed), - /// which is resolved once at construction. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed) or + /// [`permissions`](Self::permissions), which are resolved once at + /// construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -585,15 +659,55 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether Edge Cookie creation is allowed for this request. + /// Returns whether the configured Edge Cookie provider's required + /// permissions are set for this request. /// - /// Resolved once at construction from the consent context (see - /// [`consent::ec_consent_granted`]). + /// Resolved once at construction through the permission model (see + /// [`consent::ec_permission_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { self.ec_allowed } + /// 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 + /// [`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) + } + + /// Whether the Edge Cookie identifier may be shared beyond the edge for + /// this request: into the bidstream as `user.id`, in a partner identify + /// response, or in a partner sync call. + /// + /// Sharing rides on the same two permissions as bidstream EIDs (see + /// [`crate::consent::gate_eids_by_permissions`]): storage (the identifier + /// exists and is readable) and personalised-ad selection (it is shared to + /// select ads). [`ec_allowed`](Self::ec_allowed) covers only the + /// provider's own requirements, so a storage-only grant keeps first-party + /// use while withholding partner sharing. + #[must_use] + pub fn ec_sharing_allowed(&self) -> bool { + self.ec_allowed() + && self.permissions.is_set(Permission::StoreOnDevice) + && self.permissions.is_set(Permission::SelectPersonalisedAds) + } + + /// Returns the permissions resolved for this request. + /// + /// Assembled once at construction, the country/region baseline augmented by + /// the session's signals. The core gates provider execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions + } + /// Returns the existing EC cookie value for revocation handling. /// /// When consent is withdrawn, this value is needed to identify the @@ -610,21 +724,19 @@ impl EcContext { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` whose creation gate is derived from the - /// consent context, matching the production construction path. + /// Creates a test-only `EcContext` with the permission gate open. /// /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs - /// an explicit gate. + /// the gate closed. #[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) + Self::new_for_test_gated(ec_value, consent, true) } - /// Creates a test-only `EcContext` with an explicit creation gate. + /// Creates a test-only `EcContext` with an explicit permission gate. /// - /// `ec_allowed` stands in for the gating decision the production path + /// `ec_allowed` stands in for the permission decision the production path /// resolves at construction, so a test can exercise the gate-open and /// gate-closed branches directly. #[cfg(test)] @@ -634,6 +746,15 @@ impl EcContext { consent: ConsentContext, ec_allowed: bool, ) -> Self { + let permissions = if ec_allowed { + PermissionState::new( + [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] + .into_iter() + .collect(), + ) + } else { + PermissionState::default() + }; Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), @@ -641,6 +762,8 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -660,14 +783,15 @@ 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, + ec_allowed: true, + storage_acquisition: Acquisition::default(), + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, @@ -698,6 +822,8 @@ impl EcContext { ec_generated, consent, ec_allowed, + permissions: PermissionState::default(), + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -839,21 +965,6 @@ mod tests { } } - /// 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; @@ -962,8 +1073,7 @@ mod tests { .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)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, None) .expect("should run generation"); @@ -1033,8 +1143,7 @@ mod tests { // 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)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, Some(&graph)) .expect("should generate and persist"); @@ -1100,8 +1209,7 @@ mod tests { 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)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); let err = ec @@ -1161,8 +1269,7 @@ mod tests { 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)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, Some(&graph)) .expect("should generate and persist"); @@ -1183,13 +1290,15 @@ mod tests { #[test] fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { - let settings = create_test_settings(); + let mut settings = create_test_settings(); + // A granted-baseline default jurisdiction, so the storage permission + // is set with no signal and the mint runs. + settings.geo.default_country = Some("US/CA".to_owned()); 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)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, None) .expect("should generate"); @@ -1231,6 +1340,18 @@ mod tests { ); } + #[test] + fn sharing_requires_the_personalised_ads_permission_not_just_storage() { + let mut ec = + EcContext::new_for_test(Some(valid_ec_id("a", "ABC123")), ConsentContext::default()); + ec.permissions = PermissionState::new([Permission::StoreOnDevice].into_iter().collect()); + assert!(ec.ec_allowed(), "the provider gate is open"); + assert!( + !ec.ec_sharing_allowed(), + "storage alone must not allow sharing beyond the edge" + ); + } + #[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 index 1d7fb7de7..993eac085 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -3,7 +3,8 @@ //! 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, or [`HostSignals`] for -//! the TLS/HTTP-2 fingerprints)//! (the adapter, through [`build_provider`]) supplies instances per request. A +//! the TLS/HTTP-2 fingerprints) as `Arc`, and the composition root +//! (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. //! @@ -18,6 +19,7 @@ use error_stack::Report; use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::{HostSignals, RequestInfo}; +use crate::permissions::{Permission, PermissionSet, PermissionState}; use crate::redacted::Redacted; use crate::settings::Ec; @@ -28,14 +30,18 @@ use super::generation; /// 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. +/// for behavior beyond gating. The gate has already confirmed the provider's +/// required permissions are set before `generate` is called. #[derive(Default)] pub struct IdentityInput<'a> { + /// The permissions resolved for this request, when the calling path carries + /// them. A provider reads this only for behavior beyond gating. The main + /// organic path supplies them; the publisher path passes `None`. + pub permissions: Option<&'a PermissionState>, + /// 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. + /// logic. The core gates on permissions, not consent, so a provider reads + /// this only to forward or record consent. [`HmacProvider`] ignores it. pub consent: Option<&'a ConsentContext>, } @@ -57,9 +63,12 @@ pub struct GeneratedEdgeCookie { /// 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. +/// Implementations are selected by configuration and come in two types, which +/// reach the same outcome (a `ts-ec` cookie) by different routes: +/// +/// - **Server-side** (for example [`HmacProvider`]): derives the identifier at +/// the edge in [`generate`](Self::generate), and the page response sets the +/// cookie. Nothing client-side is involved. /// /// A provider returns `Ok(None)` from [`generate`](Self::generate) when it /// cannot derive an identifier at the edge, so the request proceeds without an @@ -192,6 +201,7 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Derives an Edge Cookie identifier from the provider's injected services /// and the request's gating context. /// + /// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. @@ -230,6 +240,17 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { fn normalize_id_for_kv(&self, value: &str) -> String { generation::normalize_ec_id_for_kv(value) } + + /// The permissions this provider's data use requires. + /// + /// Trusted Server executes the provider only when every permission returned + /// here is set. The default is empty, so a vendor-neutral provider requires + /// no permission. A provider that stores identity on the device, or shares it + /// onward, declares the matching permission so the request's country and + /// signal rules can gate it. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } } /// The built-in HMAC Edge Cookie provider. @@ -270,6 +291,13 @@ impl EdgeCookieProvider for HmacProvider { response_headers: Vec::new(), }) } + + fn required_permissions(&self) -> PermissionSet { + // The HMAC provider writes the Edge Cookie to the device, so it requires + // permission to store on the device (TCF Purpose 1). Whether that needs a + // signal is decided by the country rules, not by the provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } } /// The built-in host-signal Edge Cookie provider. @@ -328,6 +356,12 @@ impl EdgeCookieProvider for HostSignalProvider { response_headers: Vec::new(), }) } + + fn required_permissions(&self) -> PermissionSet { + // Writes the Edge Cookie to the device, so it requires necessary.operations.storage + // (TCF Purpose 1), the same gate as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } } /// Builds the Edge Cookie provider named by the `[ec] provider` selector, @@ -344,9 +378,8 @@ impl EdgeCookieProvider for HostSignalProvider { /// /// Returns [`TrustedServerError::EdgeCookie`] when the selected provider requires /// a service the host did not supply (for example the host-signal provider on a -/// host that exposes no [`HostSignals`]), or when a selected vendor provider is -/// not injected by the adapter, so a misconfigured deployment fails loudly -/// rather than minting a degraded identifier or silently running stateless. +/// host that exposes no [`HostSignals`]), so a misconfigured deployment fails +/// loudly rather than minting a degraded identifier. pub fn build_provider( ec: &Ec, host_signals: Option>, @@ -434,6 +467,10 @@ impl EdgeCookieProvider for SharedProvider { fn normalize_id_for_kv(&self, value: &str) -> String { self.0.normalize_id_for_kv(value) } + + fn required_permissions(&self) -> PermissionSet { + self.0.required_permissions() + } } #[cfg(test)] @@ -489,6 +526,7 @@ mod tests { "an identifier with another provider's code is never owned" ); } + use crate::permissions::PermissionMaps; use crate::redacted::Redacted; fn test_passphrase() -> Redacted { @@ -595,7 +633,21 @@ mod tests { } #[test] - fn host_signal_provider_mints_from_fingerprints() { + fn hmac_provider_requires_store_on_device() { + let provider = HmacProvider::new(test_passphrase()); + let required = provider.required_permissions(); + assert!( + required.contains(Permission::StoreOnDevice), + "the HMAC provider writes a cookie, so it requires necessary.operations.storage" + ); + assert!( + !required.contains(Permission::SelectPersonalisedAds), + "the HMAC provider requires no advertising permissions" + ); + } + + #[test] + fn host_signal_provider_mints_from_fingerprints_and_requires_store_on_device() { let signals = Arc::new(TestHostSignals { ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), h2: Some("1:65536;4:6291456".to_owned()), @@ -609,22 +661,70 @@ mod tests { generated.id.is_some(), "the host-signal provider mints an identifier from the fingerprints" ); + assert!( + provider + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal provider writes a cookie, so it requires necessary.operations.storage" + ); + } + + /// A minimal provider that overrides nothing optional, used to prove the + /// trait defaults. + #[derive(Debug)] + struct MinimalProvider; + + impl EdgeCookieProvider for MinimalProvider { + fn id(&self) -> &'static str { + "minimal" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0mi") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } } #[test] - fn host_signal_provider_defers_without_fingerprints() { - let signals = Arc::new(TestHostSignals { - ja4: None, - h2: None, - }); - let provider = HostSignalProvider::new(test_passphrase(), signals); - let request_info = test_request_info(); - let generated = provider - .generate(&request_info, &IdentityInput::default()) - .expect("should generate"); + fn a_neutral_provider_requires_no_permissions_by_default() { + // MinimalProvider does not override required_permissions, so it + // inherits the trait default of none and requires no permission. assert!( - generated.id.is_none(), - "with no host fingerprints the provider should defer rather than mint an IP-only identifier" + MinimalProvider.required_permissions().is_empty(), + "a vendor-neutral provider requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacProvider::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the provider's required permission is not set, so Trusted + // Server would not commit the Edge Cookie. + assert!( + !maps.resolve(None, None, |_| false).all_set(required), + "the floor should not run the Edge Cookie provider without the permission set" + ); + + // A grant signal for necessary.operations.storage: the provider's permission is now set. + assert!( + maps.resolve(None, None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie provider runs once necessary.operations.storage is set" ); } @@ -642,4 +742,21 @@ mod tests { "the error should name the selected provider, got: {err}" ); } + + #[test] + fn host_signal_provider_defers_without_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "with no host fingerprints the provider should defer rather than mint an IP-only identifier" + ); + } } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..cca91d8fc 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -55,10 +55,13 @@ 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 sharing is not permitted or there is no active EC ID. +/// Pull sync sends the identifier to a partner, so it needs the same +/// permission pair as bidstream EIDs (storage plus personalised-ad +/// selection), not only the provider's storage permission. #[must_use] pub fn build_pull_sync_context(ec_context: &EcContext) -> Option { - if !ec_context.ec_allowed() { + if !ec_context.ec_sharing_allowed() { return None; } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index d53b118aa..f3d6d2431 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -67,9 +67,8 @@ pub fn generate_ec_id( // The provider reads request data (the client IP, and on a fingerprinting // host the TLS/HTTP-2 signals) 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 + // The publisher path applies the permission gate at the call site, and the + // built-in provider reads neither the resolved permissions nor consent, so // they are not threaded here. let generated = provider.generate(&request_info, &IdentityInput::default())?; let generated = crate::ec::provider::GeneratedEdgeCookie { 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 8eaa82a75..5d1016130 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1546,6 +1546,10 @@ passphrase = "test-secret-key-32-bytes-minimum" enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1580,6 +1584,10 @@ passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 06b987d6e..e60169f16 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1841,8 +1841,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. + // EIDs resolved from the KV identity graph and gated on the + // resolved permission state in `handle_auction` via + // `gate_eids_by_permissions`. eids: request.user.eids.clone(), } .to_ext(), @@ -3028,6 +3029,10 @@ provider = "hmac" [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; /// Parse a TOML string containing only the `[integrations.prebid]` section diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 3801ed9f3..31bc119ae 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 permissions; pub mod platform; pub mod price_bucket; pub mod proxy; diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs new file mode 100644 index 000000000..5e31c31b6 --- /dev/null +++ b/crates/trusted-server-core/src/permissions.rs @@ -0,0 +1,1495 @@ +//! Provider permissions: a technical permission model gating provider execution. +//! +//! A provider (Edge Cookie, device, or geo) advertises the [`Permission`]s its +//! data use *requires*. Trusted Server resolves which permissions are currently +//! *set* from the session's signals and the country it resolves to, and refuses +//! to execute a provider whose required permissions are not set. +//! +//! The vocabulary is the IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +//! Europe purposes and used **only** as a technical identifier for a permission. +//! Two purposes have no Data Use yet: TCF purpose 1 (device storage) uses a +//! proposed `necessary.operations.storage` key and TCF purpose 11 keeps its TCF +//! identifier. No CMP or TCF *policy* is implemented here, and +//! only [`Permission::StoreOnDevice`] (TCF Purpose 1) and +//! [`Permission::SelectPersonalisedAds`] (TCF Purpose 4) are resolved against a +//! session signal today. The remaining purposes are modeled for forward +//! compatibility. +//! +//! How a permission is acquired varies by country, so resolution is keyed on the +//! ISO 3166-1 country code a geo provider returns. [`PermissionMaps::standard`] +//! loads the default country and region rules from the embedded +//! `permissions.yaml` (see `DEFAULT_PERMISSION_RULES`). +//! When no country is identified (no geo provider, or a lookup that resolves +//! nothing) or the resolved country/region has no rule, resolution uses the +//! deployer's configured default country (`[geo] default_country`). With none +//! configured, a permission is set only when the incoming signals explicitly +//! grant it. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::Deserialize; + +/// 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. +/// +/// Only the identifier is used, with no TCF or taxonomy policy implemented. Only +/// [`Permission::StoreOnDevice`] (Purpose 1) and +/// [`Permission::SelectPersonalisedAds`] (Purpose 4) are resolved against a +/// signal today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Permission { + /// TCF Purpose 1, store and/or access information on a device. Resolved + /// against a session signal today. No IAB Privacy Taxonomy Data Use exists + /// for device storage yet, so this uses a proposed `necessary.operations` + /// key pending an upstream addition. + StoreOnDevice, + /// TCF Purpose 2, use limited data to select advertising. + SelectBasicAds, + /// TCF Purpose 3, create profiles for personalised advertising. + CreateAdsProfile, + /// TCF Purpose 4, use profiles to select personalised advertising. + SelectPersonalisedAds, + /// TCF Purpose 5, create profiles to personalise content. + CreateContentProfile, + /// TCF Purpose 6, use profiles to select personalised content. + SelectPersonalisedContent, + /// TCF Purpose 7, measure advertising performance. + MeasureAdPerformance, + /// TCF Purpose 8, measure content performance. + MeasureContentPerformance, + /// TCF Purpose 9, understand audiences through statistics. + MarketResearch, + /// TCF Purpose 10, develop and improve services. + DevelopServices, + /// TCF Purpose 11, use limited data to select content. No IAB Privacy + /// Taxonomy Data Use exists for limited-data content selection yet, so this + /// keeps its TCF identifier and is proposed upstream. Not gated today. + SelectBasicContent, + /// An IAB Privacy Taxonomy Data Use with no dedicated variant, identified by + /// its index into [`EXTRA_DATA_USES`]. These carry a policy flag in + /// `permissions.yaml` for completeness; no provider gates on them today. + Extra(u8), +} + +/// The Data Use identifiers for the named [`Permission`] variants, in variant +/// order (bit index 0..11). +const NAMED_DATA_USES: [&str; 11] = [ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual", + "advertising_marketing.profiling", + "advertising_marketing.first_party.targeted", + "advertising_marketing.personalize.profiling", + "advertising_marketing.personalize.content", + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.content_performance", + "analytics.ad_reporting.market_research", + "necessary.operations.improve", + "select-basic-content", +]; + +/// Every other IAB Privacy Taxonomy Data Use, carried so `permissions.yaml` can +/// set a policy flag for the whole taxonomy (bit index 11..). No provider gates +/// on these today; they exist for completeness, testing, and demonstration. +const EXTRA_DATA_USES: [&str; 53] = [ + "advertising_marketing", + "advertising_marketing.communications", + "advertising_marketing.communications.email", + "advertising_marketing.communications.sms", + "advertising_marketing.first_party", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + "advertising_marketing.personalize", + "advertising_marketing.personalize.system", + "advertising_marketing.serving", + "advertising_marketing.third_party", + "advertising_marketing.third_party.targeted", + "analytics", + "analytics.ad_reporting", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_fraud_detection", + "analytics.ad_reporting.ad_viewability", + "analytics.ad_reporting.campaign_insights", + "analytics.reporting", + "analytics.reporting.system", + "disclosure", + "disclosure.law_enforcement", + "disclosure.outside_counsel", + "disclosure.sale", + "disclosure.share", + "disclosure.third_party_sale", + "functional", + "functional.performance", + "functional.personalization", + "functional.security", + "necessary", + "necessary.employment", + "necessary.employment.hr", + "necessary.employment.hr.hiring", + "necessary.fraud_detection", + "necessary.legal_obligation", + "necessary.legal_obligation.age_verification", + "necessary.legal_obligation.content_moderation", + "necessary.legal_obligation.dsr", + "necessary.legal_obligation.hold", + "necessary.operations", + "necessary.operations.authentication", + "necessary.operations.debugging", + "necessary.operations.notifications", + "necessary.operations.notifications.email", + "necessary.operations.notifications.sms", + "necessary.operations.payment_processing", + "necessary.operations.quality_assurance", + "necessary.operations.security", + "necessary.operations.support", + "necessary.operations.survey", + "necessary.operations.upgrades", + "necessary.operations.website_use", +]; + +impl Permission { + /// The named permission variants, in bit-index order. + const NAMED: [Permission; 11] = [ + Permission::StoreOnDevice, + Permission::SelectBasicAds, + Permission::CreateAdsProfile, + Permission::SelectPersonalisedAds, + Permission::CreateContentProfile, + Permission::SelectPersonalisedContent, + Permission::MeasureAdPerformance, + Permission::MeasureContentPerformance, + Permission::MarketResearch, + Permission::DevelopServices, + Permission::SelectBasicContent, + ]; + + /// Every modeled permission: the named variants, then every other Privacy + /// Taxonomy Data Use. + pub fn all() -> impl Iterator { + Self::NAMED + .into_iter() + .chain((0..EXTRA_DATA_USES.len() as u8).map(Permission::Extra)) + } + + /// The Data Use identifier for this permission. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Permission::Extra(index) => EXTRA_DATA_USES[index as usize], + named => NAMED_DATA_USES[named.index() as usize], + } + } + + /// The stable bit position for this permission within a [`PermissionSet`]. + #[must_use] + const fn index(self) -> u8 { + match self { + Permission::StoreOnDevice => 0, + Permission::SelectBasicAds => 1, + Permission::CreateAdsProfile => 2, + Permission::SelectPersonalisedAds => 3, + Permission::CreateContentProfile => 4, + Permission::SelectPersonalisedContent => 5, + Permission::MeasureAdPerformance => 6, + Permission::MeasureContentPerformance => 7, + Permission::MarketResearch => 8, + Permission::DevelopServices => 9, + Permission::SelectBasicContent => 10, + Permission::Extra(index) => 11 + index, + } + } + + /// The single-bit mask for this permission within a [`PermissionSet`]. + const fn bit(self) -> u128 { + 1 << self.index() + } + + /// Returns the permission whose Data Use identifier matches `id` (for + /// example `"necessary.operations.storage"`), or `None` when it is unknown. + /// + /// Used to parse permission names from `permissions.yaml`. + #[must_use] + pub fn from_identifier(id: &str) -> Option { + Permission::all().find(|p| p.as_str() == id) + } +} + +impl core::fmt::Display for Permission { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A set of [`Permission`]s, stored as a bitset keyed by each permission's bit +/// index. +/// +/// Used both for what a provider requires and for what Trusted Server has set. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionSet(u128); + +impl PermissionSet { + /// The empty set, requiring or containing nothing. + #[must_use] + pub const fn none() -> Self { + Self(0) + } + + /// Returns this set with `permission` added. + #[must_use] + pub const fn with(self, permission: Permission) -> Self { + Self(self.0 | permission.bit()) + } + + /// Whether `permission` is in the set. + #[must_use] + pub const fn contains(self, permission: Permission) -> bool { + self.0 & permission.bit() != 0 + } + + /// Whether the set is empty. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every permission in `other` is also in this set. + #[must_use] + pub const fn contains_all(self, other: PermissionSet) -> bool { + self.0 & other.0 == other.0 + } + + /// Iterates the permissions in the set, in TCF purpose order. + /// + /// The built-ins read nothing from the full set; this serves a provider or + /// diagnostic path that enumerates what is present. + pub fn iter(self) -> impl Iterator { + Permission::all().filter(move |p| self.contains(*p)) + } +} + +impl FromIterator for PermissionSet { + fn from_iter>(iter: I) -> Self { + iter.into_iter() + .fold(PermissionSet::none(), PermissionSet::with) + } +} + +/// How a permission is acquired in a given country. +/// +/// This is intentionally country-keyed, not provider-keyed: a provider only +/// advertises *which* permissions it needs, and the country's rules decide *how* +/// each is obtained. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Acquisition { + /// Set without any signal, exempt or strictly necessary here. + Granted, + /// Set only when the incoming signals grant the matching TCF purpose. + /// The default, matching the floor an unresolved location falls to. + #[default] + RequiresSignal, + /// Never set in this country. + Denied, +} + +/// What a session signal says about a permission, layered on top of the +/// country/region baseline by the consent mapping. +/// +/// The core never reads consent directly. A caller maps its consent model (or +/// any other signal source) to a [`ConsentSignal`] per permission, and the +/// permission model applies it: a [`Grant`](Self::Grant) sets a +/// `RequiresSignal` permission, a [`Revoke`](Self::Revoke) drops a `Granted` one +/// (an opt-out), and [`Neutral`](Self::Neutral) leaves the baseline unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentSignal { + /// The signal grants this permission, so a `RequiresSignal` baseline is set. + Grant, + /// The signal withdraws this permission, dropping a `Granted` baseline. + Revoke, + /// The signal says nothing, so the baseline stands. + Neutral, +} + +/// The acquisition rule for each permission in one country or region. +/// +/// A `default` applies to any permission not explicitly overridden, so a rule +/// table stays compact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CountryRules { + default: Acquisition, + overrides: BTreeMap, +} + +impl CountryRules { + /// Rules with `default` for every permission and no per-permission override. + /// Groups in `permissions.yaml` are built from this plus [`with_rule`]. + /// + /// [`with_rule`]: Self::with_rule + #[must_use] + pub fn with_default(default: Acquisition) -> Self { + Self { + default, + overrides: BTreeMap::new(), + } + } + + /// Sets the acquisition rule for a single permission, overriding the default. + #[must_use] + pub fn with_rule(mut self, permission: Permission, acquisition: Acquisition) -> Self { + self.overrides.insert(permission.index(), acquisition); + self + } + + /// The acquisition rule for `permission`. + #[must_use] + pub fn rule_for(&self, permission: Permission) -> Acquisition { + self.overrides + .get(&permission.index()) + .copied() + .unwrap_or(self.default) + } +} + +/// Which Data Uses a signal revokes. +#[derive(Debug, Clone, Default)] +enum RevokeSet { + /// The signal revokes nothing. + #[default] + None, + /// The signal revokes every Data Use (the map bounds what a revoke drops). + All, + /// The signal revokes only the listed Data Uses. + Set(PermissionSet), +} + +/// How each session signal maps onto permissions, 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 +/// country/region baseline decides the rest. +#[derive(Debug, Clone, Default)] +pub(crate) 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. + 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. + opt_out_revokes: RevokeSet, +} + +impl SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. + pub(crate) 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 { + self.tcf_purpose.get(&permission.index()).copied() + } + + /// The signals that constitute a US-style opt-out. + pub(crate) 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 { + match &self.opt_out_revokes { + RevokeSet::None => false, + RevokeSet::All => true, + RevokeSet::Set(set) => set.contains(permission), + } + } +} + +/// 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_use) in &tcf.purposes { + let permission = Permission::from_identifier(data_use).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: data_use.clone(), + } + })?; + policy.tcf_purpose.insert(permission.index(), *purpose); + } + } + if let Some(opt_out) = &spec.us_opt_out { + policy.opt_out_sources = opt_out.sources.clone(); + policy.opt_out_revokes = match &opt_out.revokes { + RevokeSpec::Keyword(keyword) if keyword == "all" => RevokeSet::All, + RevokeSpec::Keyword(other) => { + return Err(PermissionsError::UnknownRevoke { + value: other.clone(), + }); + } + RevokeSpec::List(names) => { + let mut set = PermissionSet::none(); + for name in names { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { name: name.clone() } + })?; + set = set.with(permission); + } + RevokeSet::Set(set) + } + }; + } + Ok(policy) +} + +/// Looks up the [`CountryRules`] for a request's country and region. +/// +/// `by_country` is keyed on the ISO 3166-1 alpha-2 code a geo provider returns +/// (upper-cased). [`PermissionMaps::standard`] populates it with a default set +/// of country rules. `by_region` keeps optional, finer rules keyed by country +/// and region (for example a US state), which take precedence over the country +/// entry. A request whose country and region match no entry resolves to `None` +/// from [`rules_for`](Self::rules_for); the caller substitutes the deployer's +/// configured default country (see [`resolve_with`](Self::resolve_with)). +#[derive(Debug, Clone, Default)] +pub struct PermissionMaps { + by_country: BTreeMap, + by_region: BTreeMap, + 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. +const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../permissions.yaml"); + +/// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. +fn region_key(country: &str, region: &str) -> String { + format!( + "{}:{}", + country.to_ascii_uppercase(), + region.to_ascii_uppercase() + ) +} + +impl PermissionMaps { + /// Builds an empty map set with no country or region entries. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// The signal-to-permission policy parsed from the `signals` section of + /// `permissions.yaml`. The consent mapping reads this rather than encoding + /// any signal policy in the code. + #[must_use] + pub(crate) fn signals(&self) -> &SignalPolicy { + &self.signals + } + + /// Registers explicit rules for an ISO 3166-1 alpha-2 country code. + #[must_use] + pub fn with_country(mut self, iso_code: &str, rules: CountryRules) -> Self { + self.by_country.insert(iso_code.to_ascii_uppercase(), rules); + self + } + + /// Registers explicit rules for a region within a country, keyed by the ISO + /// 3166-1 alpha-2 country and the geo provider's region code (for example + /// `US` and `CA`). + /// + /// A region entry takes precedence over the country entry, so a deployer can + /// vary a single state or province on top of the country baseline. + #[must_use] + pub fn with_region(mut self, iso_country: &str, region: &str, rules: CountryRules) -> Self { + self.by_region + .insert(region_key(iso_country, region), rules); + self + } + + /// The built-in default rules, parsed from the embedded `permissions.yaml` + /// (see `DEFAULT_PERMISSION_RULES`). + /// + /// The parse runs once per instance and the result is cached. + /// + /// # Panics + /// + /// Panics if the embedded `permissions.yaml` fails to parse. The file is a + /// build-time constant covered by tests, so a panic means the committed file + /// is malformed, not a runtime condition. + #[must_use] + pub fn standard() -> &'static Self { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + Self::from_yaml(DEFAULT_PERMISSION_RULES) + .expect("should parse the embedded default permissions.yaml") + }) + } + + /// Builds the maps from a `permissions.yaml` document: named `groups` and + /// the `rules` that map a country or country/region to a group. + /// + /// # Errors + /// + /// Returns [`PermissionsError`] when the YAML is malformed or names an + /// unknown group, permission, or acquisition rule. + pub fn from_yaml(yaml: &str) -> Result { + let file: RulesFile = + serde_yaml_ng::from_str(yaml).map_err(|error| PermissionsError::Parse { + message: error.to_string(), + })?; + + // Build every named group into its CountryRules. + let mut groups: BTreeMap = BTreeMap::new(); + for (name, flags) in &file.groups { + groups.insert(name.clone(), group_rules(name, flags)?); + } + + let mut maps = Self::empty(); + // Rule keys are matched case-insensitively at lookup, so two spellings + // of one country or region would silently overwrite each other. Reject + // the collision instead. + let mut seen_rule_keys: BTreeMap = BTreeMap::new(); + for (key, spec) in &file.rules { + if let Some(first) = seen_rule_keys.insert(key.to_ascii_uppercase(), key) { + return Err(PermissionsError::DuplicateRule { + first: first.to_owned(), + second: key.clone(), + }); + } + let rules = match spec { + RuleSpec::Group(name) => resolve_group(&groups, name)?, + RuleSpec::Detailed(detail) => apply_modifications( + resolve_group(&groups, &detail.group)?, + &detail.permissions, + )?, + }; + // A `country/region` key (for example `US/CA`) layers a region rule + // on top of its country; a bare `country` key sets the country rule. + match key.split_once('/') { + Some((country, region)) => maps = maps.with_region(country, region, rules), + None => maps = maps.with_country(key, rules), + } + } + maps.signals = build_signal_policy(&file.signals)?; + Ok(maps) + } + + /// Returns the rules that apply to `country` and `region`, preferring a + /// region entry, then the country entry, or `None` when neither matches. + #[must_use] + pub fn rules_for(&self, country: Option<&str>, region: Option<&str>) -> Option<&CountryRules> { + if let (Some(country), Some(region)) = (country, region) + && let Some(rules) = self.by_region.get(®ion_key(country, region)) + { + return Some(rules); + } + country + .map(str::to_ascii_uppercase) + .and_then(|code| self.by_country.get(&code)) + } + + /// The rules for `country`/`region`, falling back to the configured default + /// location when the request's own country and region match no rule. + /// + /// Returns `None` only when neither resolves (no default configured, or the + /// default itself has no rule), which the caller treats as the + /// requires-signal floor. In a validated deployment this is unreachable: a + /// default is required and checked at startup by + /// [`GeoConfig::validate_default_country`](crate::settings::GeoConfig::validate_default_country), + /// so a resolvable default always exists. The floor remains the behavior for + /// an unconfigured map, exercised by unit tests rather than reached at + /// runtime. + pub(crate) fn rules_or_default( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + ) -> Option<&CountryRules> { + self.rules_for(country, region) + .or_else(|| self.rules_for(default_country, default_region)) + } + + /// Resolves the permission state for a request: the country/region baseline + /// augmented by a session signal. + /// + /// `country` and `region` are what a geo provider returns (`region` may be + /// `None`). `default_country`/`default_region` are the deployer's configured + /// default location, used when the request's own country and region match no + /// rule. When neither matches (no default configured, or the default has no + /// rule) every permission is `RequiresSignal`, so nothing is set without a + /// signal. `signal` maps each permission to a [`ConsentSignal`]; the caller + /// derives it from its consent model so this module stays independent of how + /// a signal is decoded. A `Granted` baseline is set unless the signal is + /// `Revoke`, a `RequiresSignal` baseline is set only on `Grant`, and `Denied` + /// is never set. + #[must_use] + pub fn resolve_with( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + signal: impl Fn(Permission) -> ConsentSignal, + ) -> PermissionState { + let rules = self.rules_or_default(country, region, default_country, default_region); + let acquisition = + |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); + let set = Permission::all() + .filter( + |&permission| match (acquisition(permission), signal(permission)) { + (Acquisition::Denied, _) => false, + (Acquisition::Granted, ConsentSignal::Revoke) => false, + (Acquisition::Granted, _) => true, + (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, + (Acquisition::RequiresSignal, _) => false, + }, + ) + .collect(); + PermissionState { set } + } + + /// The baseline permission state for a country and region with no session + /// signal. + /// + /// Permissions exist without a consent model, so this is the set of + /// `Granted` permissions for the location (or the configured default), and is + /// what a request resolves to when no signal is present. + #[must_use] + pub fn baseline( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + ) -> PermissionState { + self.resolve_with(country, region, default_country, default_region, |_| { + ConsentSignal::Neutral + }) + } + + /// Convenience over [`resolve_with`](Self::resolve_with) for a boolean + /// signal with no region and no revocation: a `true` grants the permission + /// and a `false` is neutral. + #[must_use] + pub fn resolve( + &self, + country: Option<&str>, + default_country: Option<&str>, + signal: impl Fn(Permission) -> bool, + ) -> PermissionState { + self.resolve_with(country, None, default_country, None, |permission| { + if signal(permission) { + ConsentSignal::Grant + } else { + ConsentSignal::Neutral + } + }) + } +} + +/// The permissions Trusted Server currently has set for a request. +/// +/// A provider executes only when [`all_set`](Self::all_set) of its required +/// permissions returns `true`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionState { + set: PermissionSet, +} + +impl PermissionState { + /// Builds a state in which exactly the permissions in `set` are set, for + /// tests and callers that compute the set directly. + #[must_use] + pub const fn new(set: PermissionSet) -> Self { + Self { set } + } + + /// Whether a single permission is set. + #[must_use] + pub const fn is_set(&self, permission: Permission) -> bool { + self.set.contains(permission) + } + + /// Whether every permission in `required` is set. An empty requirement is + /// always satisfied, so a provider that requires nothing always runs. + #[must_use] + pub const fn all_set(&self, required: PermissionSet) -> bool { + self.set.contains_all(required) + } + + /// The full set of permissions that are set, for a provider that adapts its + /// behavior to whatever is present. + #[must_use] + pub const fn permissions(&self) -> PermissionSet { + self.set + } +} + +// --------------------------------------------------------------------------- +// permissions.yaml parsing +// --------------------------------------------------------------------------- + +/// The shape of a `permissions.yaml` document. +#[derive(Debug, Deserialize)] +struct RulesFile { + /// Named permission baselines, keyed by group name. Each group is a flat map + /// of `default` plus optional per-permission flags. + #[serde(default)] + groups: BTreeMap>, + /// Rules keyed by country (`FR`) or country and region (`US/CA`). + #[serde(default)] + rules: BTreeMap, + /// How each session signal maps onto Data Uses. + #[serde(default)] + signals: SignalsSpec, +} + +/// The `signals` section: how each signal source maps onto Data Uses. Parsed +/// into a [`SignalPolicy`] by [`build_signal_policy`]. +#[derive(Debug, Default, Deserialize)] +struct SignalsSpec { + /// The TCF record mapping, or `None` when the file declares no TCF policy. + #[serde(default)] + tcf: Option, + /// The US-style opt-out mapping, or `None` when none is declared. + #[serde(default)] + us_opt_out: Option, +} + +/// The `signals.tcf` block. +#[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 it grants (and revokes when the record + /// does not consent to that purpose). + #[serde(default)] + purposes: BTreeMap, +} + +/// The `signals.us_opt_out` block. +#[derive(Debug, Deserialize)] +struct OptOutSpec { + /// The signals that constitute a US-style opt-out. + #[serde(default)] + sources: Vec, + /// Which Data Uses the opt-out revokes. + #[serde(default)] + revokes: RevokeSpec, +} + +/// A `revokes` value: the keyword `all`, or an explicit list of Data Uses. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RevokeSpec { + /// A bare keyword, expected to be `all`. + Keyword(String), + /// An explicit list of Data Use identifiers. + List(Vec), +} + +impl Default for RevokeSpec { + fn default() -> Self { + RevokeSpec::List(Vec::new()) + } +} + +/// A single US-style opt-out signal source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OptOutSource { + /// The `Sec-GPC` request header (Global Privacy Control). + Gpc, + /// A GPP US sale opt-out. + GppSaleOptOut, + /// A US Privacy string sale opt-out. + UsPrivacyOptOut, +} + +/// Default for `#[serde(default = ...)]` on a `bool` field that should be `true`. +fn default_true() -> bool { + true +} + +/// A rule entry: either a bare group name, or a group with explicit +/// per-permission acquisition overrides applied on top. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RuleSpec { + /// A bare group name, for example `gdpr-eu`. + Group(String), + /// A group with per-permission overrides. + Detailed(DetailedRuleSpec), +} + +/// A rule with a `permissions` map of Data Use to acquisition rule +/// (`granted`, `requires_signal`, or `denied`), each overriding the group's +/// baseline for that Data Use. Unknown keys are rejected so a mistyped field +/// fails loudly. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetailedRuleSpec { + group: String, + #[serde(default)] + permissions: BTreeMap, +} + +/// Resolves an acquisition rule name to its [`Acquisition`]. +fn parse_acquisition(value: &str) -> Result { + match value { + "granted" => Ok(Acquisition::Granted), + "requires_signal" => Ok(Acquisition::RequiresSignal), + "denied" => Ok(Acquisition::Denied), + other => Err(PermissionsError::UnknownAcquisition { + value: other.to_owned(), + }), + } +} + +/// Builds a group's [`CountryRules`] from its flag map. Each key names a +/// permission and its flag; an optional `default` key sets any permission the +/// group omits. A group without a `default` must list every permission, so its +/// meaning is fully explicit (this is how the shipped groups are written). +fn group_rules( + name: &str, + flags: &BTreeMap, +) -> Result { + let default = flags + .get("default") + .map(|value| parse_acquisition(value)) + .transpose()?; + // With no `default`, every permission must be listed, so this placeholder is + // never consulted once completeness is checked below. + let mut rules = CountryRules::with_default(default.unwrap_or(Acquisition::Denied)); + let mut listed = PermissionSet::none(); + for (key, value) in flags { + if key == "default" { + continue; + } + let permission = Permission::from_identifier(key) + .ok_or_else(|| PermissionsError::UnknownPermission { name: key.clone() })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + listed = listed.with(permission); + } + if default.is_none() { + for permission in Permission::all() { + if !listed.contains(permission) { + return Err(PermissionsError::IncompleteGroup { + group: name.to_owned(), + permission: permission.to_string(), + }); + } + } + } + Ok(rules) +} + +/// Looks up a group by name, erroring when a rule references a group that is not +/// defined. +fn resolve_group( + groups: &BTreeMap, + name: &str, +) -> Result { + groups + .get(name) + .cloned() + .ok_or_else(|| PermissionsError::UnknownGroup { + name: name.to_owned(), + }) +} + +/// Applies a rule's per-permission acquisition overrides on top of its group's +/// rules. Each entry maps a Data Use to `granted`, `requires_signal`, or +/// `denied`, overriding the group's baseline for that Data Use, so any +/// acquisition (not just grant or deny) is expressible per rule. +fn apply_modifications( + mut rules: CountryRules, + modifications: &BTreeMap, +) -> Result { + for (name, value) in modifications { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: name.to_owned(), + } + })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + } + Ok(rules) +} + +/// An error parsing a `permissions.yaml` document. +#[derive(Debug, derive_more::Display)] +pub enum PermissionsError { + /// Two rule keys are the same country or region spelled differently. + #[display("rule keys `{first}` and `{second}` name the same location; keep one")] + DuplicateRule { first: String, second: String }, + /// The YAML was malformed or did not match the expected shape. + #[display("failed to parse permission rules: {message}")] + Parse { message: String }, + /// A group without a `default` did not list every permission. + #[display( + "permission group `{group}` has no `default` and is missing a flag for `{permission}` (list every permission, or add a `default`)" + )] + IncompleteGroup { group: String, permission: String }, + /// A rule referenced a group that is not defined. + #[display("unknown permission group `{name}`")] + UnknownGroup { name: String }, + /// A permission flag or modification named an unknown permission. + #[display("unknown permission `{name}`")] + UnknownPermission { 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 }, + /// A `signals` opt-out `revokes` value was neither `all` nor a list. + #[display("unknown revoke rule `{value}` (expected `all` or a list of Data Uses)")] + UnknownRevoke { value: String }, +} + +impl core::error::Error for PermissionsError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_set_membership_and_subset() { + let set = PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds); + + assert!(set.contains(Permission::StoreOnDevice)); + assert!(set.contains(Permission::SelectBasicAds)); + assert!( + !set.contains(Permission::SelectPersonalisedAds), + "an absent permission should not be reported as present" + ); + + let required = PermissionSet::none().with(Permission::StoreOnDevice); + assert!(set.contains_all(required), "a subset should be contained"); + assert!( + !required.contains_all(set), + "a superset is not contained in a subset" + ); + assert!( + set.contains_all(PermissionSet::none()), + "the empty requirement is always satisfied" + ); + } + + #[test] + fn permission_set_iterates_in_bit_index_order() { + let set = PermissionSet::none() + .with(Permission::SelectBasicAds) + .with(Permission::StoreOnDevice); + let order: Vec<&str> = set.iter().map(Permission::as_str).collect(); + assert_eq!( + order, + vec![ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual" + ], + "iteration should be in stable bit-index order" + ); + } + + #[test] + fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { + // Empty maps and no default: every permission is the requires-signal + // floor, set only when a signal grants it. + let maps = PermissionMaps::default(); + + let denied = maps.resolve(Some("GB"), None, |_| false); + assert!( + !denied.is_set(Permission::StoreOnDevice), + "the floor should not set necessary.operations.storage without a signal" + ); + + let granted = maps.resolve(Some("GB"), None, |p| p == Permission::StoreOnDevice); + assert!( + granted.is_set(Permission::StoreOnDevice), + "the floor should set necessary.operations.storage once a signal grants it" + ); + } + + #[test] + fn unknown_country_uses_the_configured_default() { + // A map with a granted "us" rule, used as the default for unknown geo. + let maps = PermissionMaps::empty() + .with_country("us", CountryRules::with_default(Acquisition::Granted)); + // No country, default US: the US (granted) rule applies. + assert!( + maps.resolve(None, Some("US"), |_| false) + .is_set(Permission::StoreOnDevice), + "the configured default should set permissions when geo gives no country" + ); + // No country and no default: the requires-signal floor sets nothing. + assert!( + !maps + .resolve(None, None, |_| false) + .is_set(Permission::StoreOnDevice), + "with no default, an unknown country sets nothing without a signal" + ); + } + + #[test] + fn a_matching_country_is_used_over_the_default() { + // US grants; the default points at an opt-in "de" rule. + let maps = PermissionMaps::empty() + .with_country("us", CountryRules::with_default(Acquisition::Granted)) + .with_country( + "de", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + // US has its own rule, used directly even when a default is configured. + assert!( + maps.resolve(Some("US"), Some("DE"), |_| false) + .is_set(Permission::StoreOnDevice), + "a country with its own rule uses it, not the default" + ); + // An unmapped country falls through to the default (de, requires signal). + assert!( + !maps + .resolve(Some("ZZ"), Some("DE"), |_| false) + .is_set(Permission::StoreOnDevice), + "an unmapped country uses the default rule" + ); + } + + #[test] + fn per_permission_override_beats_the_country_default() { + // Granted by default, but deny necessary.operations.storage specifically. + let rules = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + let maps = PermissionMaps::empty().with_country("zz", rules); + let state = maps.resolve(Some("ZZ"), None, |_| true); + + assert!( + !state.is_set(Permission::StoreOnDevice), + "an explicit Denied override should beat the granted default" + ); + assert!( + state.is_set(Permission::SelectBasicAds), + "other permissions should still follow the granted default" + ); + } + + #[test] + fn all_set_gates_on_the_required_set() { + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)); + + assert!( + state.all_set(PermissionSet::none()), + "a provider requiring nothing always runs" + ); + assert!( + state.all_set(PermissionSet::none().with(Permission::StoreOnDevice)), + "a set requirement is satisfied" + ); + assert!( + !state.all_set(PermissionSet::none().with(Permission::SelectPersonalisedAds)), + "an unset requirement is not satisfied" + ); + } + + #[test] + fn with_default_sets_the_baseline_acquisition() { + // A granted default is set with no signal required. + let granted = PermissionMaps::empty() + .with_country("zz", CountryRules::with_default(Acquisition::Granted)); + assert!( + granted + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a granted default should set with no signal" + ); + // A requires-signal default is set only once a signal grants it. + let opt_in = PermissionMaps::empty().with_country( + "zz", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !opt_in + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should not be set without a signal" + ); + assert!( + opt_in + .resolve(Some("ZZ"), None, |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should set once a signal grants it" + ); + } + + #[test] + fn granted_and_denied_rules_ignore_signals() { + // A Granted rule is set even when no signal is present. + let granted = CountryRules::with_default(Acquisition::RequiresSignal) + .with_rule(Permission::StoreOnDevice, Acquisition::Granted); + assert!( + PermissionMaps::empty() + .with_country("zz", granted) + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a Granted rule is set with no signal" + ); + + // A Denied rule is never set, even when every signal grants. + let denied = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + assert!( + !PermissionMaps::empty() + .with_country("zz", denied) + .resolve(Some("ZZ"), None, |_| true) + .is_set(Permission::StoreOnDevice), + "a Denied rule is never set even with a signal" + ); + } + + #[test] + fn standard_maps_eu_requires_signal_and_uk_grants_storage() { + let maps = PermissionMaps::standard(); + assert!( + !maps + .resolve(Some("DE"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "an EU country should not set necessary.operations.storage without a signal" + ); + assert!( + maps.resolve(Some("DE"), None, |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "an EU country should set necessary.operations.storage once a signal grants it" + ); + assert!( + maps.resolve(Some("GB"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "the UK should grant necessary.operations.storage without a signal" + ); + } + + #[test] + fn standard_maps_us_and_australia_grant_storage_by_default() { + let maps = PermissionMaps::standard(); + for code in ["US", "AU"] { + assert!( + maps.resolve(Some(code), None, |_| false) + .is_set(Permission::StoreOnDevice), + "{code} should grant necessary.operations.storage by default" + ); + } + // No default configured, so an unmapped country hits the requires-signal + // floor and sets nothing without a signal. + assert!( + !maps + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "an unmapped country with no default sets nothing without a signal" + ); + } + + #[test] + fn resolve_with_revokes_a_granted_permission_on_opt_out() { + // US grants necessary.operations.storage by default; an opt-out signal revokes it. + let maps = PermissionMaps::standard(); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::StoreOnDevice), + "the US baseline should set necessary.operations.storage" + ); + let revoked = maps.resolve_with(Some("US"), None, None, None, |p| { + if p == Permission::StoreOnDevice { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + }); + assert!( + !revoked.is_set(Permission::StoreOnDevice), + "an opt-out signal should revoke a granted permission" + ); + } + + #[test] + fn a_region_entry_overrides_the_country_baseline() { + // US grants by default; a state can require a signal instead. + let maps = PermissionMaps::standard().clone().with_region( + "US", + "CA", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !maps + .baseline(Some("US"), Some("CA"), None, None) + .is_set(Permission::StoreOnDevice), + "the CA region rule should require a signal, overriding the US baseline" + ); + assert!( + maps.baseline(Some("US"), Some("NY"), None, None) + .is_set(Permission::StoreOnDevice), + "a state with no region entry should follow the US baseline" + ); + } + + #[test] + fn from_yaml_parses_groups_rules_and_modifications() { + let yaml = r#" +groups: + eu: + default: requires_signal + us: + default: granted +rules: + FR: eu + US: us + US/CA: + group: eu + permissions: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: denied +"#; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the rules"); + + // Bare group references. + assert!( + !maps + .baseline(Some("FR"), None, None, None) + .is_set(Permission::StoreOnDevice), + "FR (eu) requires a signal for device storage" + ); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::StoreOnDevice), + "US (us) grants device storage" + ); + + // CA references the eu group, but +necessary.operations.storage grants it, overriding + // the eu baseline. + assert!( + maps.baseline(Some("US"), Some("CA"), None, None) + .is_set(Permission::StoreOnDevice), + "+necessary.operations.storage grants it for CA, overriding the eu baseline" + ); + // -advertising_marketing.first_party.contextual denies it: not set even when a signal grants it. + assert!( + !maps + .resolve_with(Some("US"), Some("CA"), None, None, |_| ConsentSignal::Grant) + .is_set(Permission::SelectBasicAds), + "-advertising_marketing.first_party.contextual denies it even when a signal grants it" + ); + + // An unmapped country with a default of `US` uses the us (granted) rule. + assert!( + maps.baseline(Some("ZZ"), None, Some("US"), None) + .is_set(Permission::StoreOnDevice), + "an unmapped country uses the configured default (us, granted)" + ); + // With no default, an unmapped country hits the requires-signal floor. + assert!( + !maps + .baseline(Some("ZZ"), None, None, None) + .is_set(Permission::StoreOnDevice), + "with no default, an unmapped country sets nothing" + ); + } + + #[test] + fn from_yaml_rejects_unknown_group() { + let err = PermissionMaps::from_yaml("groups: {}\nrules:\n US: nope\n") + .expect_err("a rule naming an undefined group should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownGroup { .. }), + "should report an unknown group, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_an_incomplete_group_without_default() { + // A group with no `default` must list every permission, so this one + // (only necessary.operations.storage) is rejected rather than silently leaving the + // other ten unset. + let yaml = "groups:\n g:\n necessary.operations.storage: granted\nrules: {}\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an incomplete group without a default should be rejected"); + assert!( + matches!(err, PermissionsError::IncompleteGroup { .. }), + "should report an incomplete group, got {err:?}" + ); + } + + #[test] + fn from_yaml_accepts_an_explicit_group_listing_every_permission() { + // The shipped style: no `default`, every permission spelled out. + let mut group = String::from("groups:\n everything:\n"); + for permission in Permission::all() { + group.push_str(&format!(" {permission}: granted\n")); + } + let yaml = format!("{group}rules:\n US: everything\n"); + let maps = PermissionMaps::from_yaml(&yaml).expect("an explicit group should parse"); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::MarketResearch), + "every listed permission should take its flag" + ); + } + + #[test] + fn from_yaml_rejects_unknown_permission() { + let yaml = "groups:\n g:\n default: granted\n not-a-permission: denied\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown permission should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownPermission { .. }), + "should report an unknown permission, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_unknown_acquisition() { + let yaml = "groups:\n g:\n default: maybe\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown acquisition should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_a_non_acquisition_override_value() { + let yaml = "groups:\n g:\n default: granted\nrules:\n US:\n group: g\n permissions:\n necessary.operations.storage: enabled\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown acquisition value should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_duplicate_rule_keys_differing_only_by_case() { + let yaml = "groups:\n g:\n default: granted\nrules:\n us: g\n US: g\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("two spellings of one country should be rejected"); + assert!( + matches!(err, PermissionsError::DuplicateRule { .. }), + "should report the duplicate rule, got {err:?}" + ); + } + + #[test] + fn a_detailed_rule_can_set_requires_signal_per_permission() { + let yaml = "groups:\n g:\n default: granted\nrules:\n US:\n group: g\n permissions:\n necessary.operations.storage: requires_signal\n"; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the override map"); + let rules = maps + .rules_for(Some("US"), None) + .expect("should resolve the US rule"); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "the per-permission map should express requires_signal" + ); + assert_eq!( + rules.rule_for(Permission::SelectPersonalisedAds), + Acquisition::Granted, + "an unlisted permission should keep the group default" + ); + } + + #[test] + fn every_eu_and_eea_member_requires_a_signal_for_storage() { + // The shipped permissions.yaml must cover all 27 EU member states and + // the three EEA members, each resolving storage as requires-signal, so + // no member state silently falls to the deployer default. + let maps = PermissionMaps::standard(); + for country in [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", + "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", + "LI", "NO", + ] { + let rules = maps + .rules_for(Some(country), None) + .unwrap_or_else(|| panic!("`{country}` should have a rule")); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "storage in `{country}` should require a signal" + ); + } + } + + #[test] + fn from_yaml_parses_the_signals_section_into_a_policy() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + FR: g +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] +"; + 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" + ); + assert!( + !signals.opt_out_revokes(Permission::StoreOnDevice), + "an unlisted Data Use is not revoked by the opt-out" + ); + } + + #[test] + fn from_yaml_rejects_an_unknown_revoke_keyword() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + FR: g +signals: + us_opt_out: + sources: [gpc] + revokes: everything +"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown revoke keyword should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownRevoke { .. }), + "should report an unknown revoke rule, got {err:?}" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1bb200555..3b6283e45 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -85,10 +85,12 @@ use crate::settings::Settings; /// A geo provider that resolves nothing. /// -/// Installed when `[geo] provider = "none"` is selected, so a client IP is -/// never sent to any host geo service. Every geo consumer already treats -/// [`GeoInfo`] as optional, so a `None` result degrades gracefully (the -/// jurisdiction is unknown, the auction omits geo, and so on). +/// Installed when no geo provider is selected (`"none"` spells the same +/// choice explicitly), so a client IP is never sent to any host geo service +/// and a default deployment is not tied to any host geo capability. Every geo +/// consumer already treats [`GeoInfo`] as optional, so a `None` result +/// degrades gracefully (the permission baseline falls back to the configured +/// `[geo] default_country`, the auction omits geo, and so on). pub struct DisabledGeo; impl PlatformGeo for DisabledGeo { @@ -99,12 +101,12 @@ impl PlatformGeo for DisabledGeo { /// Selects the geo provider named by the `[geo] provider` selector. /// -/// The host platform's geo lookup is the default: with no selector, -/// `host_default` (the adapter's platform geo implementation) resolves the -/// location, matching the behavior before the selector existed, and -/// `provider = "platform"` spells the same choice explicitly. Selecting -/// `provider = "none"` returns [`DisabledGeo`] instead, so a client IP is -/// never sent to any host geo service. A selected-but-unknown provider is +/// Returns [`DisabledGeo`] when no provider is selected, so a default +/// deployment makes no host geo call and the permission baseline comes from +/// the configured `[geo] default_country`; `provider = "none"` spells the +/// same choice explicitly. The host platform's own geo lookup is opt-in: +/// `provider = "platform"` returns `host_default`, which the adapter passes +/// as its platform geo implementation. A selected-but-unknown provider is /// rejected at startup by /// [`GeoConfig::validate_provider_selection`](crate::settings::GeoConfig::validate_provider_selection). #[must_use] @@ -113,8 +115,8 @@ pub fn build_geo_provider( host_default: Arc, ) -> Arc { match settings.geo.provider.as_deref() { - Some("none") => Arc::new(DisabledGeo), - _ => host_default, + Some("platform") => host_default, + _ => Arc::new(DisabledGeo), } } @@ -185,6 +187,14 @@ mod tests { { } + #[test] + fn disabled_geo_requires_no_permissions() { + assert!( + DisabledGeo.required_permissions().is_empty(), + "the default disabled geo provider requires no permissions" + ); + } + #[test] fn runtime_services_can_be_constructed_and_cloned() { let services = noop_services(); @@ -211,18 +221,25 @@ mod tests { } #[test] - fn build_geo_provider_defaults_to_the_host_geo() { + fn build_geo_provider_defaults_to_no_geo() { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_provider(&settings, Arc::clone(&host)); assert!( - Arc::ptr_eq(&host, &selected), - "default settings should use the host geo" + !Arc::ptr_eq(&host, &selected), + "default settings should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the default geo provider should resolve nothing" ); } #[test] - fn build_geo_provider_none_selects_no_geo() { + fn build_geo_provider_none_selects_no_geo_explicitly() { let mut settings = Settings::default(); settings.geo.provider = Some("none".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); @@ -231,13 +248,6 @@ mod tests { !Arc::ptr_eq(&host, &selected), "provider none should not use the host geo" ); - assert!( - selected - .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) - .expect("disabled geo lookup should not fail") - .is_none(), - "the disabled geo provider should resolve nothing" - ); } #[test] diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index 6bceabd7c..4a6095403 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -150,4 +150,12 @@ pub trait PlatformGeo: Send + Sync { /// Returns [`PlatformError::Geo`] when the platform geo lookup fails /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. fn lookup(&self, client_ip: Option) -> Result, Report>; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the default (disabled) geo provider requires no + /// permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b0d63b82a..e6558cdab 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -54,7 +54,7 @@ use crate::auction::types::{ use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::creative_opportunities::{AssemblyMode, CreativeOpportunitiesConfig}; @@ -4078,12 +4078,18 @@ pub async fn handle_publisher_request( // this handler; subresource requests are likewise filtered there. let ec_allowed = ec_context.ec_allowed(); log::debug!( - "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}", + "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}, sharing={}", ec_context.ec_value().is_some(), + ec_context.ec_sharing_allowed(), ); let consent_context = ec_context.consent().clone(); - let ec_id = ec_context.ec_value().filter(|_| ec_allowed); + // The identifier forwarded into the auction request (user.id) is sharing + // beyond the edge, so it rides the same permission pair as bidstream EIDs + // (storage plus personalised-ad selection), not only the provider's gate. + let ec_id = ec_context + .ec_value() + .filter(|_| ec_context.ec_sharing_allowed()); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); @@ -4953,10 +4959,10 @@ fn apply_auction_eids_and_device( let merged_eids = merge_auction_eids(client_eids, kv_eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + gate_eids_by_permissions(merged_eids, targeting.ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { log::warn!( - "{} auction EIDs stripped by TCF consent gating", + "{} auction EIDs stripped by permission gating", targeting.path_label ); } @@ -6396,7 +6402,12 @@ pub async fn handle_page_bids( }; let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); - let ec_id = ec_context.ec_value().filter(|_| ec_context.ec_allowed()); + // Same sharing pair as the navigation path: page-bids builds an auction + // request, so its user.id egress needs storage plus personalised-ad + // selection, matching the EID gate. + let ec_id = ec_context + .ec_value() + .filter(|_| ec_context.ec_sharing_allowed()); let consent_context = ec_context.consent(); let geo = ec_context.geo_info().cloned(); let cookie_jar = handle_request_cookies(&req)?; diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index d2a413a71..63c15fc97 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -218,6 +218,10 @@ mod tests { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "US" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a5838163d..fccfbb92f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -808,26 +808,52 @@ impl DeviceConfig { pub struct GeoConfig { /// The key of the geo provider to activate. /// - /// The host platform's geo lookup is the default when absent, matching the - /// behavior before this selector existed; `provider = "platform"` spells - /// the same choice explicitly. Selecting `provider = "none"` resolves no - /// geolocation and makes no host geo call, so a deployment can opt out of - /// any host geo service. Override it with the + /// No provider is the default: Trusted Server resolves no geolocation and + /// makes no host geo call, so a default deployment is not tied to any host + /// geo service, and the permission baseline comes from + /// [`default_country`](Self::default_country). `provider = "none"` spells + /// the same choice explicitly. The host platform's own geo lookup is + /// opt-in via `provider = "platform"`. Override it with the /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled /// WebAssembly can switch providers at deployment. An unknown key is rejected /// at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). #[serde(default)] pub provider: Option, + + /// The country, or country and region, whose permission rules apply when the + /// geo provider returns no country, or returns a country/region that has no + /// rule in `permissions.yaml`. One value, the same form as a + /// `permissions.yaml` rule key, a country code (`US`) or country/region + /// (`US/CA`), matched case-insensitively. + /// + /// Required. There must always be a default permission set, so startup fails + /// when this is unset. The value must resolve to a rule in `permissions.yaml`, + /// both checked at startup by + /// [`validate_default_country`](Self::validate_default_country). + #[serde(default)] + pub default_country: Option, + + /// Acknowledges that, with no geo provider, every request is treated as + /// coming from [`default_country`](Self::default_country). + /// + /// With geolocation off, a visitor from any other jurisdiction silently + /// receives the default jurisdiction's permission rules. A deployment that + /// runs an Edge Cookie provider without a geo provider must set this to + /// `true`, checked at startup by + /// [`validate_jurisdiction_acknowledgment`](Self::validate_jurisdiction_acknowledgment), + /// so serving a single jurisdiction is an explicit operator decision rather + /// than an accident of the default configuration. + #[serde(default)] + pub assume_single_jurisdiction: bool, } impl GeoConfig { /// Validates that the selected geo provider is available in this build. /// - /// No selector is valid and is the default, selecting the host platform's - /// geo lookup. The explicit `"none"` runs without geolocation, the same - /// way the Edge Cookie provider runs statelessly when `"none"` is - /// selected. + /// No selector is valid and is the default, running without geolocation, + /// the same way the Edge Cookie provider runs statelessly when none is + /// selected. The explicit `"none"` spells the same choice. /// /// # Errors /// @@ -841,6 +867,83 @@ impl GeoConfig { })), } } + + /// Validates that `default_country` is set and resolves to a rule in the + /// compiled `permissions.yaml`. + /// + /// A default is required: it is the permission baseline for a request the geo + /// provider leaves unmatched, so there must always be one. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when `default_country` is + /// unset, or is set but matches no country or country/region rule. + pub fn validate_default_country(&self) -> Result<(), Report> { + let Some(spec) = self.default_country.as_deref() else { + return Err(Report::new(TrustedServerError::Configuration { + message: "[geo] default_country is required. There must always be a default \ + permission set, so set the country (`US`) or country/region \ + (`US/CA`) whose permissions.yaml rule applies to a request the geo \ + provider leaves unmatched" + .to_owned(), + })); + }; + let (country, region) = match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }; + if crate::permissions::PermissionMaps::standard() + .rules_for(country, region) + .is_none() + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[geo] default_country `{spec}` matches no rule in permissions.yaml \ + (use a country code like `US` or country/region like `US/CA` that \ + has a rule)" + ), + })); + } + Ok(()) + } + + /// Validates that running jurisdiction consumers without geolocation is + /// explicitly acknowledged. + /// + /// With no geo provider, every request resolves to the + /// [`default_country`](Self::default_country) permission baseline, so a + /// visitor from any other jurisdiction silently receives the default + /// jurisdiction's rules. That is acceptable only as an explicit operator + /// decision. When an Edge Cookie provider is configured (the permission + /// model gates it by jurisdiction) and no geo provider is selected, + /// [`assume_single_jurisdiction`](Self::assume_single_jurisdiction) must be + /// `true`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when an Edge Cookie + /// provider is configured, no geo provider is selected, and + /// `assume_single_jurisdiction` is not set. + pub fn validate_jurisdiction_acknowledgment( + &self, + ec: &Ec, + ) -> Result<(), Report> { + let geo_disabled = !matches!(self.provider.as_deref(), Some("platform")); + let ec_active = ec + .provider + .as_deref() + .is_some_and(|provider| provider != "none"); + if geo_disabled && ec_active && !self.assume_single_jurisdiction { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider is configured but no [geo] provider is selected, so \ + every request would be treated as [geo] default_country. Set \ + [geo] assume_single_jurisdiction = true to acknowledge \ + single-jurisdiction operation, or select a geo provider" + .to_owned(), + })); + } + Ok(()) + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -3058,6 +3161,10 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; + settings.geo.validate_default_country()?; + settings + .geo + .validate_jurisdiction_acknowledgment(&settings.ec)?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3067,6 +3174,27 @@ impl Settings { ); } + // Log the configured default jurisdiction baseline once per settings + // load, so an operator can see which permissions the unmatched-request + // default grants without a signal. + if let Some(spec) = settings.geo.default_country.as_deref() { + let (country, region) = match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }; + let baseline = crate::permissions::PermissionMaps::standard() + .baseline(country, region, country, region); + let granted: Vec = baseline + .permissions() + .iter() + .map(|permission| permission.to_string()) + .collect(); + log::info!( + "Permission baseline: [geo] default_country = {spec}; granted without a signal: [{}]", + granted.join(", ") + ); + } + Ok(settings) } @@ -4625,14 +4753,16 @@ mod tests { let config = GeoConfig::default(); assert!( config.provider.is_none(), - "geo should default to no selector, which selects the host geo" + "geo should default to no selector, which selects no geo provider" ); config .validate_provider_selection() - .expect("should validate the default host geo selection"); + .expect("should validate the default of running without geolocation"); let platform = GeoConfig { provider: Some("platform".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; platform .validate_provider_selection() @@ -4640,12 +4770,16 @@ mod tests { let none = GeoConfig { provider: Some("none".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; none.validate_provider_selection() .expect("should validate the explicit opt-out of geolocation"); let unknown = GeoConfig { provider: Some("acme".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; assert!( unknown.validate_provider_selection().is_err(), @@ -4681,6 +4815,72 @@ mod tests { ); } + #[test] + fn validate_default_country_checks_against_permissions_yaml() { + // Unset is rejected: a default permission set is required, so startup + // fails without one. + assert!( + GeoConfig::default().validate_default_country().is_err(), + "an unset default country should be rejected" + ); + + // A country, and a country/region, that have a rule are accepted. + for spec in ["US", "FR", "US/CA"] { + let config = GeoConfig { + provider: None, + default_country: Some(spec.to_owned()), + assume_single_jurisdiction: false, + }; + config + .validate_default_country() + .unwrap_or_else(|e| panic!("default `{spec}` should validate, got {e:?}")); + } + + // A code with no rule is rejected at startup. + let bad = GeoConfig { + provider: None, + default_country: Some("ZZ".to_owned()), + assume_single_jurisdiction: false, + }; + assert!( + bad.validate_default_country().is_err(), + "a default country with no rule should be rejected" + ); + } + + #[test] + fn ec_without_geo_requires_the_single_jurisdiction_acknowledgment() { + // The base test settings acknowledge single-jurisdiction operation. + // Removing the acknowledgment while an EC provider is configured and + // no geo provider is selected must fail at startup. + let toml_str = crate_test_settings_str().replace("assume_single_jurisdiction = true\n", ""); + let err = Settings::from_toml(&toml_str) + .expect_err("an EC provider with no geo provider needs the acknowledgment"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + + // Selecting a geo provider removes the requirement. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("[geo]", "[geo]\n provider = \"platform\""); + Settings::from_toml(&toml_str) + .expect("a geo provider resolves jurisdictions, so no acknowledgment is needed"); + + // With no EC provider there is no jurisdiction consumer to protect. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("provider = \"hmac\"", "") + .replace("[ec.providers.hmac]\n passphrase = \"test-secret-key-32-bytes-minimum\"", ""); + Settings::from_toml(&toml_str) + .expect("stateless operation needs no jurisdiction acknowledgment"); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -5628,6 +5828,9 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -5657,6 +5860,10 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -6821,6 +7028,10 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -7151,6 +7362,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -7238,6 +7453,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -7277,6 +7496,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -7322,6 +7545,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index f755a0bcd..a49417cb8 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -21,6 +21,15 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + # A gdpr-eu country, where every permission requires a signal. This + # reproduces the prior no-default floor, so existing tests are + # unaffected by the now-required default. + default_country = "FR" + # Tests run with no geo provider, so single-jurisdiction operation + # is acknowledged the same way a deployment would. + assume_single_jurisdiction = true + [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" 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 497205d4c..6459a05d0 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 @@ -9,11 +9,17 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" -# The lifecycle scenarios need a resolved jurisdiction for the consent gate, -# and Viceroy supplies the host geo lookup. The permission model replaces this -# with a [geo] default_country baseline in the next PR of the series. +# Viceroy does not resolve geolocation for the loopback test client, so the +# request carries no country and default_country supplies the baseline the +# permission model uses. US/CA is a US opt-out state (every purpose granted +# without a signal), so the EC lifecycle scenarios can assert +# permission-driven behavior, for example GPC revoking necessary.operations.storage so the +# EC is no longer permitted and the ts-ec cookie is expired. [geo] -provider = "platform" +default_country = "US/CA" +# Viceroy runs without a geo provider, so every request is the default +# jurisdiction by design. +assume_single_jurisdiction = true [ec] provider = "hmac" diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 853a48ee9..3459fd5c0 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -47,6 +47,10 @@ fn test_settings() -> Settings { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse parity test settings") diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md new file mode 100644 index 000000000..0e89e5b59 --- /dev/null +++ b/docs/guide/permission-model.md @@ -0,0 +1,303 @@ +# Permission Model + +Trusted Server runs an Edge Cookie, device, or geo provider only when the +technical permissions that provider requires are set. The permission model is +how a deployer's policy decides whether those permissions are set, without that +policy being baked into the core. + +## Privacy is a spectrum + +Privacy is a spectrum, not a binary, and Trusted Server is technology that is +neutral on policy. Different deployers operate under different laws and run +different policies, so it is the deployer who decides how to configure the +stack. Trusted Server provides the mechanism to establish and check permissions, +and the deployer brings the policy that decides how permissions are established +and what they allow. + +The default deployment makes no host-specific call, creates no identifiers, and +resolves no location until an operator enables a provider. It requires exactly +one policy decision, the default jurisdiction baseline (`[geo] default_country`). +Trusted Server does not assume a jurisdiction for you, so you declare one, and +the examples use the most protective baseline (GDPR-EU). With no Edge Cookie +provider selected there is nothing to gate, so no identifier is created and the +request proceeds. + +## Separating legal policy from the core + +The core does not encode any jurisdiction's law or any single policy. A provider +advertises the technical permissions its data use requires, and the core runs +the provider only when every required permission is set. A provider that +requires nothing always runs, so a vendor-neutral default shows no consent +dialogue and needs no per-request policy interaction. + +## Permission sources + +Permissions are the single currency every service and provider reads. A provider +never reads consent, a consent framework, or any other source directly. It sees +only the resulting permissions, so it cannot depend on how they were derived. + +```mermaid +flowchart LR + G["Country / region"] --> P[["Permissions
(the stable set)"]] + C["Consent signals
(TCF, GPP, GPC)"] --> P + I["Interaction with
the user"] --> P + X["External data
(extension, profile)"] --> P + P --> S["Providers and services
(Edge Cookie, device, geo)"] +``` + +A request's permissions are set by one or more **permission sources**. Consent +is one source among several, not the basis for every permission: + +- **Country and region.** The baseline position for a jurisdiction, from the geo + provider, keyed by ISO 3166-1 with an optional region such as a US state. When + no country is identified, or the country/region has no rule, the deployer's + configured default country applies. A default 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. +- **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 + profile from an external service. + +The model gates on whether a permission is _set_, not on how it was +established, so any of these sources plugs into the same mechanism. + +### Why this matters + +Implementors of services, features, and providers are protected from the method +used to derive the current request's permissions. They work against a clean, +stable set of permissions that does not change when laws, consent frameworks, or +signal sources change. A new GPP section, a new opt-out signal, or a revised +jurisdiction rule changes a _source_, never the permission a provider checks. + +If a source carries a distinction a consumer needs but no existing permission can +express, the fix is to add a permission to the model, never to leak the source +into the consumer. + +## The permission vocabulary + +The permission names are IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +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 +consent and privacy signals. A present TCF record grants or revokes each purpose +directly, and where no TCF record is present a US-style opt-out (GPC, a GPP sale +opt-out, or a US Privacy opt-out) is honored. 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. + +`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 +provider may gate on them. Every other Data Use is listed for completeness and, +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: + +| # | Data Use identifier | IAB TCF Europe purpose | +| --- | ----------------------------------------------- | ----------------------------------------------- | +| 1 | `necessary.operations.storage` | Store and/or access information on a device | +| 2 | `advertising_marketing.first_party.contextual` | Use limited data to select advertising | +| 3 | `advertising_marketing.profiling` | Create profiles for personalised advertising | +| 4 | `advertising_marketing.first_party.targeted` | Use profiles to select personalised advertising | +| 5 | `advertising_marketing.personalize.profiling` | Create profiles to personalise content | +| 6 | `advertising_marketing.personalize.content` | Use profiles to select personalised content | +| 7 | `analytics.ad_reporting.measure_ad_performance` | Measure advertising performance | +| 8 | `analytics.ad_reporting.content_performance` | Measure content performance | +| 9 | `analytics.ad_reporting.market_research` | Understand audiences through statistics | +| 10 | `necessary.operations.improve` | Develop and improve services | +| 11 | `select-basic-content` | Use limited data to select content | + +## How providers use permissions + +A provider advertises a required permission set. The core resolves the +permissions it has set for the request, then runs the provider only when every +required permission is set. + +| Provider | Requires | Effect when not set | +| ------------------------- | ------------------------------ | ------------------------- | +| Built-in HMAC Edge Cookie | `necessary.operations.storage` | No Edge Cookie is created | +| A vendor-neutral provider | nothing | Always runs | + +The Edge Cookie `Set-Cookie` operation always requires `necessary.operations.storage` +(Purpose 1), because writing the cookie stores information on the device. + +## Groups and rules + +The policy lives in a human-editable `permissions.yaml` at the repository root, +compiled into the build, so policy owners read and change it in version control. +It has two parts. **Groups** are named baselines, each a set of permission flags. +**Rules** map a country, or a country and state, to a group. The keys are the +codes a geo provider returns, matched case-insensitively. The country is an +ISO 3166-1 alpha-2 code (`FR`), and a state adds the ISO 3166-2 subdivision code +with no country prefix (`US/CA` is California). The Fastly and other geo +providers both emit these codes directly. A region rule takes precedence over its +country. A request that matches no rule, or whose country the geo provider could +not resolve, uses the deployer's configured default country +(`[geo] default_country`). A default is required, so there is always one. + +The country and region rules set only the **baseline** position. They say what +is permitted before any session signal, not what a deployer must ask the user +for. Session signals are then layered on top, and the deployer's own policy +decides how those signals are gathered. + +Each permission flag in a group is one of three acquisition rules, which a +session signal can then change: + +| Flag | Baseline, and how a session signal changes it | +| ----------------- | ------------------------------------------------------------------- | +| `granted` | Set by default, unless a signal revokes it (for example an opt-out) | +| `requires_signal` | Not set by default, set only when a signal grants it | +| `denied` | Never set, even when a signal grants it | + +A group lists every permission and its flag, so its meaning is explicit in the +file. (A group may instead give a single `default` flag for any permission it +omits, but the shipped groups spell every one out.) A rule may then make small +per-permission tweaks on top of its group with a `permissions` map from Data +Use to flag (`granted`, `requires_signal`, or `denied`), each entry overriding +the group baseline for that Data Use. + +### Why three states, not two + +`requires_signal` and `denied` both start unset, so they can look like the same +"off" state, but they answer different questions and a session signal treats +them differently. + +- `requires_signal` means the Data Use is permitted **with** a signal. A grant, + for example TCF consent to the mapped purpose, sets it. +- `denied` means the Data Use is not permitted here at all. A grant **cannot** + set it. This models a jurisdiction where there is no lawful basis for the use, + so a consent signal is irrelevant. + +For a worked example, take a deployer who sets +`advertising_marketing.profiling: denied` for a country that does not permit +profiling. A request arrives with a TCF string that consents to Purpose 3 +(create profiles for personalised advertising), which maps to that Data Use. The +resolver pairs the `denied` baseline with the grant signal and still leaves the +permission **unset**, so a provider that requires profiling does not run. The +same consent against a `requires_signal` baseline would set it. Consent lifts +`requires_signal`; it never lifts `denied`. + +The shipped `permissions.yaml` defines `gdpr-eu`, `gdpr-uk`, and `us-opt-out` +groups, and maps the EU 27 and the EEA members (IS, LI, NO) to `gdpr-eu`, the +UK to `gdpr-uk`, and the US (with all 50 states and DC) and Australia to +`us-opt-out`. For device storage (Purpose 1), that yields: + +| Country | Device storage (Purpose 1) | +| -------------- | --------------------------------------------------------------- | +| EU 27 and EEA | Requires signal (opt-in) | +| United Kingdom | Granted (no signal required under the reformed ePrivacy regime) | +| United States | Granted (opt-out) | +| Australia | Granted | + +These are defaults to modify or replace, not legal advice. The deployer must set +the default country for unmatched requests in `trusted-server.toml` +(`[geo] default_country`). It is required and validated at startup against these +rules, so startup fails when it is unset or names no rule. A rule that names a +group not defined in the file, or a flag that is not `granted`, +`requires_signal`, or `denied`, is rejected at build time, so a typo is caught +rather than silently ignored. + +## How a request resolves + +A permission is _set_ when Trusted Server may rely on it for this request, and +unset otherwise. A provider runs only when every permission it requires is +set. + +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. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Lookup{Geo lookup
succeeded?} + Lookup -- "Failed" --> Floor[Requires-signal floor] + Lookup -- "Yes" --> Rules{Region or country
has a rule?} + Rules -- "Yes" --> CountryMap[Use that baseline] + Rules -- "No or none resolved" --> Default[Use the configured default country] + Floor --> PerPerm + CountryMap --> PerPerm + Default --> PerPerm + + PerPerm[For each permission] --> Rule{Baseline rule?} + Rule -- "Granted" --> Revoke{Signal
revokes?} + Revoke -- "No" --> Set[Permission set] + Revoke -- "Yes" --> Unset[Permission unset] + Rule -- "Requires signal" --> Grant{Signal
grants?} + Grant -- "Yes" --> Set + Grant -- "No" --> Unset + Rule -- "Denied" --> Unset + + Set --> Check{Provider's required
permissions all set?} + Unset --> Check + Check -- "Yes" --> Run([Run provider]) + Check -- "No" --> Skip([Skip provider]) +``` + +## Configuration + +The geo provider, which resolves the country, and the default country for +unmatched requests are selected in `trusted-server.toml`. The country and region +rules live in the human-editable `permissions.yaml` at the repository root, +compiled into the build (not loaded at runtime). + +```toml +# trusted-server.toml selects the geo provider and the default country used when +# a request matches no rule (or the geo provider resolves no country). +[geo] +provider = "platform" +default_country = "US" +# With no geo provider, every request is treated as default_country. A +# deployment that runs an Edge Cookie provider without a geo provider must +# acknowledge that explicitly: +# assume_single_jurisdiction = true +``` + +The default country covers requests the geo provider leaves unmatched. A +failed geo lookup is different: it resolves every permission to the +requires-signal floor instead of the default, so an outage is handled +protectively rather than as the deployer's default jurisdiction, and it is +logged at error level. + +```yaml +# permissions.yaml (excerpt). Each group lists every permission and its flag. +# Rules map countries and states to a group. +groups: + gdpr-eu: # opt-in, every purpose requires a signal + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + # ... the remaining purposes, also requires_signal + us-opt-out: # opt-out, every purpose granted + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + # ... the remaining purposes, also granted + +rules: + FR: gdpr-eu + US: us-opt-out + US/CA: # a state can override single flags on top of its group + group: us-opt-out + permissions: + advertising_marketing.first_party.targeted: denied +``` + +## Relationship to Edge Cookies + +Edge Cookie creation is gated through this model: the built-in HMAC provider +requires `necessary.operations.storage`, so an Edge Cookie is created only when that +permission is set. See [Edge Cookies](/guide/edge-cookies) for the full +request lifecycle. diff --git a/docs/superpowers/specs/2026-07-30-permission-model-design.md b/docs/superpowers/specs/2026-07-30-permission-model-design.md new file mode 100644 index 000000000..790f4664a --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-permission-model-design.md @@ -0,0 +1,807 @@ +# Design Spec: Jurisdiction Permission Model + +**Status:** Implemented in PR #1045; revised against the implementation, 2026-08-25. +**Author:** Engineering +**Issue references:** #779 +**Related specs:** `2026-07-30-pluggable-providers-design.md`, +`2026-07-30-provider-migration-rollout-design.md` +**Last updated:** 2026-08-25 + +> **Context.** PR #838 proposed a permission model whose review surfaced two +> classes of defect this spec exists to prevent: (1) silent behavioral +> inversions of consent-signal precedence, most seriously a present TCF string +> short-circuiting GPC/GPP/US-Privacy opt-outs, and (2) fail-open jurisdiction +> resolution when geolocation is disabled. Both defect classes are closed in +> the implementation. The precedence rules (§4) and the failure-mode matrix +> (§6) are normative and are now backed by pinning tests in +> `crates/trusted-server-core/src/ec/consent.rs`. One structural position of +> the 2026-07-31 draft was not adopted: policy remains a build-time-embedded +> `permissions.yaml`, not a `[permissions]` section of `trusted-server.toml`, +> because the runtime config push and activation apparatus the draft assumed +> does not exist yet (§3.1). Every other draft position that was narrowed, +> simplified, or deferred is recorded in §11. + +--- + +## 1. Overview + +The permission model replaces the hard-wired jurisdiction gate +(`allows_ec_creation` and its companions, now removed) with a single resolved +**permission set** per request. The data decisions Trusted Server itself makes +through this model are EC provider execution, EC creation and withdrawal, EID +transmission into the bidstream, and sharing of the EC identifier beyond the +edge (§7). Server-side auction dispatch is not yet a consumer (§7.4). + +The set is resolved from three inputs: + +1. **Jurisdiction**, the country and optional region the request resolves to + (§5). +2. **Policy**, a declarative map from jurisdiction to a baseline acquisition + rule per permission, plus a declared signal policy (§3). +3. **Signals**, the request's privacy signals, being TCF, GPP, GPC, and US + Privacy (§4). + +These are the initial sources. Issues #777 and #779 also envision publisher +interaction and external services as permission sources. That source interface +remains **explicitly deferred**, not silently dropped. §10 records the +divergence, and the documentation (`docs/guide/permission-model.md`) already +frames consent as one source among several so a later source plugs into the +same mechanism. Core code resolves permissions through a per-permission +`ConsentSignal` closure (`Grant`, `Revoke`, `Neutral`), so a new source is a +new producer of that signal, not a new resolution algorithm. + +Scope: the model governs decisions Trusted Server makes. A downstream protocol +receives the full regulatory context only where that protocol defines fields +for it (OpenRTB consent fields, and proxy-mode forwarding of raw strings). +The draft's stronger rule, that identity rows carry normalized per-permission +provenance and a digest and never a raw string, is **not yet true**: the +identity-graph entry (`KvEntry` in `ec/kv_types.rs`) stores the raw TCF and +GPP strings alongside the row today. The normalized provenance model travels +with the providers-spec storage work and is recorded as deferred (§11). + +## 2. Vocabulary: the IAB Privacy Taxonomy Data Uses + +Permissions are named by **IAB Privacy Taxonomy Data Uses**, mapped from the +IAB TCF Europe purposes and used strictly as technical identifiers. No CMP or +TCF policy is implemented by naming them. This replaces the draft's +TCF-purpose-identifier vocabulary (`store-on-device`, +`select-personalised-ads`). The taxonomy adoption postdates the 2026-07-31 +draft and follows the joint taxonomy work with the IAB Tech Lab. + +The implementation (`crates/trusted-server-core/src/permissions.rs`) models +the vocabulary in two tiers: + +- **Eleven named permissions**, one per TCF purpose 1 through 11, each with a + Data Use identifier. All eleven are resolved against the session signal + (§4): a present TCF record grants or revokes each mapped purpose. Two + purposes have no published Data Use yet, so purpose 1 uses a proposed + `necessary.operations.storage` key and purpose 11 keeps its TCF identifier + `select-basic-content`, both flagged for an upstream taxonomy addition. +- **Fifty-three additional taxonomy Data Uses**, carried so `permissions.yaml` + can declare a policy flag for the whole taxonomy. No provider gates on them + today and no signal maps to them, so their configured baseline stands. + They exist for completeness, testing, and demonstration, and where no + informed policy decision has been made the shipped file sets them `denied`. + +The two Data Uses that carry enforcement weight today are +`necessary.operations.storage` (TCF Purpose 1, storage) and +`advertising_marketing.first_party.targeted` (TCF Purpose 4, personalized-ad +selection). Provider execution gates on whatever a provider declares (the +built-in Edge Cookie providers declare storage), and sharing beyond the edge +gates on the storage plus personalized-ad pair (§7). + +This is a deliberate departure from the draft's rule that a permission appears +only when it has both a signal mapping and an enforcement point. The eleven +named Data Uses all have the signal mapping, and the fifty-three baseline-only +Data Uses are declared policy rather than enforced policy. The file header of +`permissions.yaml` states this plainly, and the `denied` default means an +undeployed flag cannot silently authorize anything. Policy validation still +**rejects** any rule or flag that references an identifier outside the modeled +vocabulary, so a policy cannot name a Data Use the code does not know. + +The eleven named Data Uses, with the TCF purpose each maps from: + +| # | Data Use identifier | TCF purpose | +| --- | ----------------------------------------------- | ----------------------------------------------- | +| 1 | `necessary.operations.storage` | Store and/or access information on a device | +| 2 | `advertising_marketing.first_party.contextual` | Use limited data to select advertising | +| 3 | `advertising_marketing.profiling` | Create profiles for personalised advertising | +| 4 | `advertising_marketing.first_party.targeted` | Use profiles to select personalised advertising | +| 5 | `advertising_marketing.personalize.profiling` | Create profiles to personalise content | +| 6 | `advertising_marketing.personalize.content` | Use profiles to select personalised content | +| 7 | `analytics.ad_reporting.measure_ad_performance` | Measure advertising performance | +| 8 | `analytics.ad_reporting.content_performance` | Measure content performance | +| 9 | `analytics.ad_reporting.market_research` | Understand audiences through statistics | +| 10 | `necessary.operations.improve` | Develop and improve services | +| 11 | `select-basic-content` | Use limited data to select content | + +(The purpose names are the IAB names verbatim, including their original +spelling.) + +## 3. Policy + +### 3.1 Location: `permissions.yaml`, compiled into the build + +Policy lives in a human-editable `permissions.yaml` at the repository root, +compiled into the binary with `include_str!` and parsed once per instance +(cached behind a `OnceLock` in `PermissionMaps::standard`). A deployer edits +or replaces the file and rebuilds to change policy. The file is not read at +runtime. + +This keeps the mechanism the draft rejected, for a reason the draft's own +premise no longer supports: the draft required policy to flow through the +runtime config pipeline (`ts config push`, staged activation, §5.5), and that +activation apparatus does not exist. Publishing a `[permissions]` TOML section +with no activation protocol would reintroduce exactly the mixed-revision and +lazy-validation hazards the draft cataloged. Until the runtime pipeline +exists, the embedded file is the safer home, and moving policy to runtime +configuration is recorded as deferred follow-up (§11), not abandoned. + +Within the embedded model, the draft's specific complaints are answered: + +- The parse runs once per instance, and the embedded file is a build-time + constant covered by unit tests, so a malformed committed file fails the + test suite rather than surfacing as a per-request failure. The documented + panic on a malformed embedded file is a build defect signal, not a runtime + condition. +- Unknown fields on a detailed rule are rejected (`deny_unknown_fields`), so + a misspelled override key fails loudly instead of being swallowed. +- Two rule keys naming the same location in different case are rejected at + parse, so one spelling cannot silently overwrite another. +- Auditability lives where the draft placed it, in version control: the file + ships in the repository, and its history is the change log. + +The `include_str!` path still reaches above the crate root, so the crate +packaging concern the draft raised remains open and moves with the runtime +follow-up. + +**Fallback posture.** The file is always present, so there is no "no policy" +state. A location that resolves no rule and has no configured default, and a +failed geo lookup, both resolve every permission to the **requires-signal +floor**: nothing is set without a signal that grants it. Absence of an +applicable rule is always safe, and there is no fail-open default. The +draft's `regime = "gdpr"` component of the protective profile has no +implemented counterpart because no regime concept exists (§3.2). + +### 3.2 Format + +Named **groups** (baselines) and **rules** mapping a country (`FR`) or +country/region pair (`US/CA`) to a group, plus a **signals** section that +declares how each session signal maps onto Data Uses. Each permission resolves +to an **acquisition rule**: + +- `granted`, set without any signal, +- `requires_signal`, set only when a signal grants it (opt-in), +- `denied`, never set, even when a signal grants it. + +```yaml +# permissions.yaml (abbreviated). The shipped file lists every Data Use in +# every group so each group's meaning is fully explicit. +groups: + gdpr-eu: + necessary.operations.storage: requires_signal + advertising_marketing.first_party.targeted: requires_signal + # ... every remaining Data Use, requires_signal or denied + gdpr-uk: + necessary.operations.storage: granted + # ... the other mapped purposes requires_signal, the rest denied + us-opt-out: + necessary.operations.storage: granted + advertising_marketing.first_party.targeted: granted + # ... the other mapped purposes granted, the rest denied + +rules: + FR: gdpr-eu + GB: gdpr-uk + US: us-opt-out + AU: us-opt-out + # A country/region key takes precedence over its country. A detailed rule + # applies explicit per-permission acquisitions on top of its group: + # US/CA: + # group: us-opt-out + # permissions: + # advertising_marketing.first_party.targeted: requires_signal + +signals: + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 4: advertising_marketing.first_party.targeted + # ... purposes 2, 3, 5..11 likewise + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: all +``` + +Format rules, as implemented: + +- A group is a flat map of Data Use to acquisition flag, with an optional + `default` key covering any permission the group omits. A group without + `default` must list **every** modeled permission exactly once, or the + parse fails (`IncompleteGroup`). The shipped groups list every Data Use. +- A detailed rule is `{group, permissions}` where `permissions` maps a Data + Use to an explicit acquisition (`granted`, `requires_signal`, or + `denied`), overriding the group baseline for exactly that Data Use. This + adopts the draft's requirement that overrides name explicit target states. + The earlier `+`/`-` sigil scheme, which could not express + `requires_signal`, is gone. +- A rule key is a bare country or a `country/region` pair. Keys are matched + case-insensitively, and a region entry takes precedence over its country + entry. +- The **signals** section is new relative to the draft: the TCF purpose to + Data Use mapping, the opt-out source list, and the opt-out revoke set are + data in the file, so no signal-to-permission policy lives in the code. The + `signals.tcf.authoritative` flag governs only whether a present TCF + record's own grants and revokes apply. It never lets a TCF record override + an opt-out (§4). The `us_opt_out.revokes` value is `all` or an explicit + list of Data Uses, so a deployer bounds what an opt-out drops. + +The draft's required per-group **`regime`** class (`gdpr`, `us-privacy`, +`none`) is **not implemented**. Its intended consumer, server-side auction +dispatch, was not migrated to the permission model (§7.4), so the field would +be inert today. It returns with the dispatch migration. + +### 3.3 Validation + +Validation runs where the policy actually enters the system: + +- **At parse**, meaning the unit tests and any `PermissionMaps::from_yaml` + caller, the file is rejected for: malformed YAML, a rule referencing an + undefined group, an unknown Data Use identifier anywhere (group flag, + detailed-rule entry, signals purpose map, or revoke list), an acquisition + value outside `granted | requires_signal | denied`, a group without + `default` that does not list every permission, duplicate rule keys under + case-insensitive comparison (`us` and `US`), unknown fields on a detailed + rule, and a `revokes` keyword other than `all`. +- **At startup**, `[geo] default_country` must be set and must resolve to a + rule in the compiled `permissions.yaml` + (`GeoConfig::validate_default_country`), and the no-geo acknowledgment + must be present where required (§5.3). Both are settings-construction + failures, never per-request failures. + +Not implemented from the draft's list, and recorded as future hardening +(§11): checking rule-key country parts against the assigned ISO 3166-1 list, +checking region parts against assigned ISO 3166-2 subdivisions, and the group +identifier grammar. A mistyped country key (`DL` for `DK`) therefore still +parses. For the shipped table the EU and EEA coverage test (§3.5) closes the +consequence the draft cared about, a member state silently dropping to the +fallback. + +### 3.4 One source of jurisdiction truth (deferred) + +Not implemented. `detect_jurisdiction`, driven by the runtime lists +`consent.gdpr.applies_in` and `consent.us_states.privacy_states`, remains the +jurisdiction source for the auction consent gate and for +`ConsentContext.jurisdiction`, while the permission model resolves against +`permissions.yaml` independently. The drift risk the draft named is real and +stands recorded: adding a country to one source has no effect on the other, +and no CI test asserts consistency between the legacy lists and the policy +table. Unifying the two, with the auction gate reading a policy regime class, +travels with the dispatch migration (§7.4) as deferred follow-up. + +### 3.5 Shipped-table coverage + +Implemented as a unit test +(`every_eu_and_eea_member_requires_a_signal_for_storage` in +`permissions.rs`): every one of the 27 EU member states plus the three EEA +members (IS, LI, NO), 30 codes in all, must have a rule, and each must +resolve `necessary.operations.storage` as `requires_signal`. A mistyped +member-state key fails this test rather than silently diverting the country +to the deployer default. The shipped table maps the EU 27 and EEA to +`gdpr-eu`, the UK to `gdpr-uk` (storage `granted` under the reformed +ePrivacy regime, everything else opt-in), and the US and Australia to +`us-opt-out`. Countries with no rule fall to the deployer's +`[geo] default_country` (§5.4). + +## 4. Signal precedence (normative, implemented) + +Precedence is **fixed in code** +(`permission_signal` in `crates/trusted-server-core/src/ec/consent.rs`), not +in policy, and runs most restrictive first. The policy file decides which +sources count and what they revoke or grant. The code decides only the order. + +1. **Policy `denied`** is never set, regardless of any signal. (Enforced in + the resolver, `PermissionMaps::resolve_with`.) +2. **A US-style opt-out always suppresses the Data Uses the policy revokes**, + regardless of any consent record present. The opt-out sources are the + `Sec-GPC` header, a GPP US sale opt-out, and a US Privacy sale opt-out, + as declared in `signals.us_opt_out.sources`. A GPC header suppresses the + revoked Data Uses even when an accompanying TCF string consents to them. + An explicit opt-out is never overridden by another signal, and the + `signals.tcf.authoritative` flag cannot change that. (This is the rule + PR #838 inverted. Four pinning tests now hold it in place, one per + opt-out source against a consenting TCF record, plus one proving a + single opt-out source suffices whatever the other signals say.) +3. **A consent record present but undecodable revokes everything.** A + malformed record is a preference that could not be read, so it fails + closed rather than degrading to the no-signal baseline, which under a + `granted` baseline would turn garbage into a grant. It never withdraws + (§4.2). An **expired** TCF record is deliberately a distinct state, not + malformed: the decoded record is cleared, the raw string is kept for + proxy forwarding, and acquisition proceeds as if the record were absent, + so the baseline applies. +4. **Only then does a present TCF record decide the mapped Data Uses**, when + `signals.tcf.authoritative` is true: granted where the record consents to + the mapped purpose, revoked where it does not, neutral where no purpose + maps. The effective record is the standalone TC string or the EU TCF + section of a GPP string (`effective_tcf`). A TCF refusal of a mapped + purpose is a revoke at this step, which drops a `granted` baseline and + leaves a `requires_signal` baseline unset. +5. **No signal leaves the baseline standing**: `granted` sets the + permission, `requires_signal` leaves it unset. + +Two simplifications against the draft's taxonomy, both recorded in §11: + +- **TCF is the only grant-class signal.** The draft's grant class also + admitted explicit GPP/USP non-opt-out values, regime-scoped, so a US rule + could be `requires_signal` yet grant on signal-carrying traffic. + The implementation instead expresses the US posture as a `granted` + baseline that opt-outs revoke, so no-signal US traffic is allowed rather + than blocked pending a signal. Explicit non-opt-out GPP/USP values grant + nothing on their own. +- **Malformed-present blocks everything, not per family.** Any present but + undecodable record (TCF, GPP, or US Privacy) revokes every Data Use for + the request, rather than blocking only the permissions mapped to the + malformed source. This is strictly more restrictive than the draft's + per-family rule. + +### 4.1 Decision matrix + +For each permission, with baseline _B_ from the resolved rule: + +| Signal state (per §4 order) | B = granted | B = requires_signal | B = denied | +| ------------------------------------------- | ----------- | ------------------- | ---------- | +| Opt-out present, Data Use in the revoke set | unset | unset | unset | +| Any record present but undecodable | unset | unset | unset | +| TCF present, consents to the mapped purpose | set | set | unset | +| TCF present, refuses the mapped purpose | unset | unset | unset | +| No signal (or neutral for this Data Use) | set | unset | unset | + +An expired TCF record resolves as the "no signal" row. Whether an unset +outcome is also a **withdrawal** is a separate, narrower question (§4.2). + +### 4.2 Withdrawal vs. absence + +Withdrawal (destructive: expire the `ts-ec` cookie, write the identity-graph +tombstone) and non-grant (the permission is simply unset, EC response +headers stripped, nothing egressed) are distinct outcomes, never conflated. +"Baseline" below means the resolved acquisition rule for +`necessary.operations.storage` in the request's jurisdiction, resolved once +at `EcContext` construction (`storage_acquisition`), never a group label. + +The implemented trigger, exhaustively (nothing else withdraws): + +1. **A TCF record refusing storage (Purpose 1) withdraws iff the baseline is + not `granted`, and only when the refusal is carried by the live + request.** Under a `requires_signal` (or `denied`) baseline the refusal + is the visitor declining the very signal storage depends on, so it + withdraws. Where the baseline is `granted`, storage never depended on + the record, so the refusal suppresses use without destroying the + identifier. Tombstones are irreversible, and PR #838 wrote them for + visitors in unregulated jurisdictions whose global CMP emitted a + purpose-refusing string. The `EcContext` consent pipeline runs without + the persisted-KV inputs, so the withdrawal decision sees live request + signals only, satisfying the draft's live-request constraint by + construction. +2. **US-style opt-outs never withdraw.** GPC and sale opt-outs are use + restrictions: they suppress the permissions the policy revokes (EC + headers stripped, nothing egressed) but never trigger destruction, so + lifting the opt-out restores the identity. +3. **A malformed record never withdraws.** It suppresses only (§4, step 3). + Destruction requires an affirmative, decodable signal. +4. **Absence of signal never destroys identity.** A visitor who has not yet + made a choice is never stripped of an existing identity. +5. **A policy change is not a user signal.** There are no runtime policy + edits (§3.1), and a rebuild that tightens a baseline does not itself + tombstone: withdrawal still requires the affirmative refusal above on a + live request. + +The draft's additional trigger, an explicit storage-withdrawal or +authenticated deletion request honored in every jurisdiction, has no +implemented carrier: no such endpoint exists. It is recorded as deferred +(§11), and when it arrives it joins this list as a global trigger. + +`ec_storage_withdrawn` (in `ec/consent.rs`, surfaced as +`EcContext::storage_withdrawn`) has direct unit coverage for every arm +above: refusal under `requires_signal` withdraws, refusal under `granted` +does not, consent does not, GPC alone does not, sale opt-outs do not, no +signal does not, malformed does not. + +### 4.3 Withdrawal durability (largely deferred) + +Implemented behavior (`ec/finalize.rs`): when the request carries the +withdrawal signal and the client presented a cookie, the response expires +the EC cookie, and the identity-graph tombstone is written for each +presented identifier the provider accepts (the incoming cookie value and +the active value). The tombstone is the authoritative revocation marker for +subsequent EC behavior. A tombstone write failure is logged at error level +and the request completes, so the write is best effort. + +The draft's durability protocol is **not implemented** and is recorded as +deferred follow-up in full: the family ID with deterministic derivation for +legacy rows, the family revocation record written before the cookie +expires, the permission-exempt suppression and authority-state records with +CAS fencing, evidence-recency comparison and anti-replay pinning, the +durable negative-intent outbox, the global identity safety breaker, and the +associated consistency and retention contracts. That machinery depends on +storage primitives (linearizable per-key CAS, independent durability +domains) the current adapters do not qualify. The 2026-07-31 draft remains +the reference design for that work. Until it lands, the known gaps the +draft called out stand: cookie expiry is not fenced on the tombstone +commit, and revocation durability is bounded by the KV store's behavior. + +### 4.4 Signal normalization + +The consent subsystem (`consent/mod.rs`) remains the decoder and +normalizer. The permission layer consumes its output only through the +per-permission `ConsentSignal` closure. The implemented pipeline: + +1. Extract raw signals from cookies and headers, and decode TCF v2, GPP, + and US Privacy. A decode failure keeps the raw string and leaves the + decoded field empty, which the permission layer reads as + malformed-present (§4, step 3). +2. Resolve standalone-TCF vs GPP-embedded-TCF conflicts per the configured + mode (`restrictive`, `permissive`, `newest`), preserving the pre-epic + selection algorithm. +3. Apply the expiry check: a TCF record older than the configured maximum + age has its decoded form cleared, the `expired` flag set, and its raw + string preserved. Expiry is its own state, excluded from + malformed-present, and resolves as absent for acquisition. +4. Construct a US Privacy string from GPC for US privacy states with no + explicit USP cookie, so the opt-out also travels in transport fields. + +The draft's declared reordering, expiry filtering **before** conflict +resolution, was **not implemented**: conflict resolution still runs first, +so the pre-epic order stands. Recorded in §11. + +**Persisted-KV consent.** When the pipeline runs with an EC ID and a KV +store (not on the `EcContext` construction path), a request carrying no +consent signals falls back to the consent persisted for that EC ID, with +the jurisdiction re-derived from the current request's geo. Staleness is +enforced by the store: entries are written with a TTL equal to +`max_consent_age_days`, so an entry older than a live record's allowed age +has expired out of the store. A live signal always wins because the +fallback is consulted only when the request carries none. The draft's +declared change, running the loaded record through the full normalization +pipeline, is not implemented: the loaded record substitutes directly. The +narrow read is permission-exempt by construction, since determining +storage cannot itself require storage. + +**Proxy mode.** Proxy mode still skips semantic decoding entirely. The +draft's minimal opt-out extraction was not implemented, but the fail-open +consequence the draft feared does not arise under the permission model: a +present record in proxy mode is present-but-undecoded, which blocks every +baseline grant (§4, step 3), and the GPC header needs no decoding, so the +GPC opt-out is honored directly. No grants are ever derived in proxy mode. +The net posture is equal to or more restrictive than the draft's row. +Absent records resolve to the baseline. + +### 4.5 US signal field mapping + +Implemented sources, as declared in the shipped `signals` section: + +| Source | Effect | +| ---------------------------------- | --------------------------------------- | +| `Sec-GPC` request header | US-style opt-out | +| GPP US section sale opt-out | US-style opt-out | +| US Privacy `opt_out_sale = Y` | US-style opt-out | +| US Privacy `opt_out_sale = N` | Nothing (no grant class exists for USP) | +| Any explicit Not Applicable value | Nothing | +| Absent / unknown / reserved values | Nothing | + +An opt-out revokes the Data Uses the policy's `revokes` value names. The +shipped file says `revokes: all`, so an opt-out drops **every** granted +Data Use, including storage. That is deliberately broader than the draft's +mapping, which scoped sale opt-outs to personalized-ad selection only, and +a deployer narrows it by listing specific Data Uses instead. No +sale-family opt-out is destructive (§4.2). + +The remainder of the draft's §4.5 is **not implemented** and is recorded +as deferred: `SharingOptOut` and `TargetedAdvertisingOptOut` as distinct +inputs, grant-class non-opt-out values, embedded-GPC detection inside GPP +sections, the per-section applicability and aggregation algorithm with its +state-over-national precedence, the mapped-section malformed blocker at +per-section granularity, the derived OpenRTB `gpp_sid` construction with +the `__gpp_sid` consistency companion, and the complete pinned section +map. The current GPP decoder surfaces the EU TCF section, the section ID +list, and a US sale opt-out. Extending it to the full pinned registry is +its own project. + +#### 4.5.1 GPP registry snapshot (deferred) + +Not implemented. The vendored registry snapshot, the pinned per-section +accepted versions, the provenance manifest, and the fixture corpus travel +with the §4.5 decoder work. The 2026-07-31 draft's §4.5.1, including the +pinned upstream commit, remains the reference for that effort. + +## 5. Jurisdiction resolution + +### 5.1 Order + +Geo resolution runs **before** permission resolution. Jurisdiction is an +input to the permission set, which is why geo providers cannot themselves +be gated on it (providers spec §5). The selected geo provider resolves a +country and optional region, and rules match `country/region` first, then +`country`, case-insensitively. Implemented in +`EcContext::read_from_request_resolving_geo` and +`PermissionMaps::rules_for`. + +### 5.2 Lookup failure + +Implemented, with the failure state carried explicitly: +`GeoStatus { Located, NoLocation, Failed }` (in `ec/consent.rs`) separates +"the provider resolved no location" from "the lookup errored". A **failed** +lookup resolves every permission to the **requires-signal floor**, never +the deployer's `[geo] default_country`, so an outage is handled +protectively rather than as the default jurisdiction. The failure is +logged at error level so it is visible. The storage-withdrawal baseline +follows the same floor, so a failed lookup cannot widen destructive +withdrawal either. + +At the floor, an explicit valid grant still counts: a TCF record consenting +to a mapped purpose sets that permission under `requires_signal`, exactly +the divergence-from-deny-all the draft declared for this row. Absent, +malformed, or refusing evidence sets nothing. + +Not implemented: a lookup-failure metric (the error log is the signal +today) and the draft's `regime = "gdpr"` component of the failure profile, +since no regime concept exists. The draft's capability check, that an +adapter whose geo implementation can never resolve anything must not accept +the selection, is part of the providers-spec provider qualification rather +than this model. + +### 5.3 No geo provider selected + +Every request resolves to `[geo] default_country`, so jurisdiction becomes +a static constant, which is only honest when the operator can genuinely +assert single-jurisdiction traffic. + +Constraint, implemented in +`GeoConfig::validate_jurisdiction_acknowledgment`: **startup fails** when +an Edge Cookie provider is configured and no geo provider is selected, +unless the operator sets `[geo] assume_single_jurisdiction = true`. This +makes the dangerous migration config (a permissive `default_country` with +geo unset) an explicit operator decision rather than an accident, closing +the highest-severity finding of the PR #838 review. + +The guard's consumer list is narrower than the draft's: the draft enumerated +every jurisdiction consumer (EC provider, regime-gated auction dispatch, +raw-EC and EID egress). In the implementation the EC provider is the only +consumer whose behavior the policy gates, because auction dispatch was not +migrated (§7.4), so the guard fires on the EC provider selection alone. When +dispatch joins the model, the guard's trigger list grows with it. + +### 5.4 Defaults: one deployer fallback plus a protective floor + +`[geo] default_country` is **required in every mode** and is validated at +startup: it must be set, and it must resolve to a rule in +`permissions.yaml`. It accepts a country (`FR`) or a country/region key +(`US/CA`), matched case-insensitively, so a no-geo single-state deployment +can select its state rule. It covers two states the draft kept separate: + +- the geo provider resolved no location (or none is configured), and +- the resolved country or country/region matches no rule. + +The draft's `rules.default` policy entry does not exist, so +"resolved-but-unmatched" falls to the same deployer default as +"unresolved". The separation the draft treated as safety-critical is the +one the implementation does keep: a **failed** lookup never reaches the +deployer default and resolves at the requires-signal floor instead (§5.2). +With no default configured startup fails, and in the unreachable +belt-and-braces case where resolution still finds no rule, the floor +applies. + +### 5.5 Policy revision activation (deferred) + +Not implemented, and currently moot: policy is compiled into the binary +(§3.1), so the deployed artifact is the policy identity and there is no +runtime activation to coordinate. The draft's activation design, covering +the JCS-canonical policy digest and ordinal pair, the activation register +and candidate protocol, fleet readiness and quiescence, admission leases, +the hash-linked activation journal with store-clock retention, and the +model-epoch transition, is the reference design for the runtime-policy +follow-up recorded in §11. Its guarantee that mixed-revision irreversible +behavior is prohibited is honored today by construction, because a +deployment runs exactly one embedded policy and destructive withdrawal +requires a live user signal (§4.2), never a policy change. + +## 6. Failure-mode matrix (normative, implemented) + +| Condition | Resolution behavior | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Geo lookup fails at request time | Requires-signal floor for every permission, never the deployer default; error logged (§5.2) | +| No geo provider configured | `default_country` baseline, guarded by `assume_single_jurisdiction` (§5.3) | +| Country resolved, no matching rule | `default_country` baseline (§5.4) | +| Region resolved, no region rule | Country rule | +| `default_country` unset or names no rule | Startup failure (§3.3) | +| EC provider configured, no geo, no acknowledgment | Startup failure (§5.3) | +| Malformed `permissions.yaml` | Parse error at build/test time; the embedded file is a build-time constant, never per-request | +| Undecodable record present (TCF, GPP, or USP) | Revokes every Data Use (fail-closed acquisition); never withdraws; opt-outs still honored | +| Expired TCF record | Distinct state, not malformed; treated as absent, so the baseline applies | +| Signals contradict (opt-out plus consent) | Opt-out wins (§4) | +| No EC provider selected | Identity fails closed: nothing minted, an incoming cookie value never used or egressed (§7) | + +The posture is fail-closed. Every ambiguous state resolves to the +configured baseline or more restrictive. + +## 7. Enforcement points + +Consumers of the resolved set in the implementation: + +1. **EC provider execution.** The provider declares + `required_permissions()`, core resolves a `PermissionState` once per + request at `EcContext` construction, and the provider executes only when + every declared permission is set (`ec_allowed`). A provider that + requires nothing always runs. **Geo** is ungated because gating it is + circular, jurisdiction being an input to permission resolution. + **Device** is ungated by a separate, deliberate decision: its + security-classification role must run for traffic that has granted + nothing, and operator selection is the recorded authorization (providers + spec §5). The built-in Edge Cookie providers declare + `necessary.operations.storage`. + +2. **EC lifecycle.** Creation requires the provider's declared permissions + through the gate above. Withdrawal follows §4.2. Recognition and + revocation of an existing identifier are never permission-gated: the + withdrawal path runs precisely when `ec_allowed` is false, reading the + raw cookie value kept for that purpose. + +3. **Sharing beyond the edge.** One predicate, + `EcContext::ec_sharing_allowed`, requires the provider gate plus + **both** `necessary.operations.storage` and + `advertising_marketing.first_party.targeted`. A storage-only grant + therefore keeps first-party use while withholding partner sharing. The + implemented inventory: + + | Path | Gate | + | -------------------------------------------- | ---------------------------------------------------------- | + | Bidstream EIDs (every auction path) | `gate_eids_by_permissions`, storage plus personalized ads | + | OpenRTB `user.id` on the `/auction` endpoint | `ec_sharing_allowed` | + | Identify endpoint (partner-facing) | `ec_sharing_allowed` | + | Pull sync (browser-request-scoped) | `ec_sharing_allowed`, from the live request resolution | + | Batch sync (context-free S2S) | Authenticated; withdrawn or missing rows are ineligible | + | KV EID resolution for auctions | `ec_allowed`, then the EID pair gate on the result | + | Publisher navigation and page-bids `user.id` | `ec_allowed` (provider gate; see the follow-up note below) | + + With **no EC provider configured**, identity fails closed: the gate is + closed rather than open by default (`ec_allowed` is false), so a cookie + value present on the request is treated as absent and never used or + egressed. This replaces PR #838's vacuously-true `is_none_or` check. + + **Follow-up note (recorded, not silent).** The publisher navigation and + page-bids paths attach the EC-derived request ID and `user.id` under + the provider gate (`ec_allowed`) rather than the sharing pair, while + their EIDs are pair-gated. With the built-in providers (storage-only + requirement), a storage-only grant can therefore still place the EC in + `user.id` on those paths. Aligning them with the `/auction` endpoint's + pair gate is recorded follow-up (§11). The draft's fuller inventory + (proxy/click/Testlight forwarding gates, the observability denylist + with typed redaction boundaries, the raw-regulatory-transport + destination allowlist, integration response cookies) is deferred with + it. Today EC values are truncated (`log_id`) before logging as the + observability mitigation. + +4. **Server-side auction dispatch (not migrated).** Dispatch is still gated + by the consent subsystem (`consent_allows_server_side_auction`), not by + the permission model: when the jurisdiction is GDPR or unknown, or an EU + TCF signal is present, dispatch requires an effective TCF record + consenting to Purpose 1, and otherwise no bid request leaves (a no-bid + response, with no PBS/APS call and no UA/IP/geo forwarding). Known + non-GDPR jurisdictions without an EU TCF signal dispatch freely. The + draft's regime-keyed dispatch table, and the `ContextualAuctionView` + positive projection for dispatch with personalized-ad selection unset, + are **not implemented**. When personalized-ad selection is unset today, + EIDs and the pair-gated identifiers are stripped but dispatch is the + ordinary request, not a contextual projection. Recorded as deferred + (§11) together with §3.4. + +The client-cycle resolve endpoint (`/_ts/api/v1/ec/resolve`) is a further +consumer: a provider-derived identifier posted by the page is accepted only +through the same provider and permission gates. + +### 7.1 Contextual OpenRTB v1 allowlist (deferred) + +Not implemented. The machine-readable projection manifest, its path +grammar, cardinalities, cross-field rules, and derivation vocabulary, and +the conformance walker over final encoded bytes, belong to the dispatch +migration (§7.4) and remain specified by the 2026-07-31 draft for that +work. + +## 8. Testing strategy + +Implemented, in `permissions.rs`, `ec/consent.rs`, `ec/mod.rs`, +`ec/finalize.rs`, and `consent/mod.rs`: + +- **Signal precedence pinning.** Four opt-out-beats-TCF tests, one per + opt-out source against a consenting TCF record + (`gpc_suppresses_storage_even_with_a_consenting_tcf_record` and + companions), plus any-single-source sufficiency. These reinstate the + behavior PR #838 inverted. +- **Withdrawal scoping.** One test per §4.2 arm: refusal under + `requires_signal` withdraws, refusal under `granted` suppresses without + destroying, consent is not a withdrawal, GPC alone never withdraws, sale + opt-outs never withdraw, no signal never withdraws, malformed never + withdraws. +- **Fail-closed acquisition.** Malformed records block baseline grants; + each undecodable record family is detected; an expired TCF record is not + treated as malformed and resolves at the baseline. +- **Geo status.** A failed lookup resolves at the requires-signal floor + (permissions and the storage baseline both), and no-location falls back + to the configured default. +- **Policy parsing and validation.** Groups, rules, detailed-rule + acquisition maps (including `requires_signal` per rule), rejection of + unknown groups, unknown permissions, unknown acquisitions, incomplete + groups, case-insensitive duplicate rule keys, unknown revoke keywords, + and the signals-section round trip. +- **Shipped-table coverage.** All 30 EU/EEA codes lock storage to + requires-signal (§3.5). +- **Vocabulary breadth.** A TCF record grants or revokes every one of the + eleven mapped purposes, not only storage and personalized ads. +- **Gates.** Provider execution blocked without its required permissions, + the sharing pair withheld on a storage-only grant, EIDs stripped when + either permission of the pair is unset, and the no-provider stateless + posture. + +The draft's fuller matrices (the complete normalization matrix as +table-driven tests, the per-row egress inventory with a denylist check, the +dispatch regime-by-signal matrix, contextual-serializer poisoning, S2S +authority denial reasons, and the §4.3 fault-injection suite) travel with +their deferred features. + +## 9. Out of scope + +- The §4.3 durability protocol, §4.5 full field mapping and registry + snapshot, §5.5 activation apparatus, §7.4 dispatch migration, and §7.1 + contextual projection: deferred follow-ups, recorded in §11 with the + 2026-07-31 draft as their reference design. Deferred, not rejected. +- Runtime policy configuration (a `[permissions]` config section): + deferred until the config push and activation pipeline exists (§3.1). +- Per-signal jurisdiction scoping (honoring GPC only where a law defines + it): rejected, as in the draft. The opt-out signal layer is + jurisdiction-free in the implementation, and the country baseline decides + only what a revoke has to drop. +- An authenticated deletion or explicit storage-withdrawal endpoint: + deferred (§4.2). + +## 10. Divergences from issue #779 + +This spec supersedes #779 on the following points, so there is one +acceptance contract, not two: + +| #779 says | This spec says | Why | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Unmatched countries fall to `default_country` | Adopted: unmatched and unresolved requests both fall to the required `[geo] default_country`; a **failed** lookup floors instead (§5) | The failure state is the one that must never reach a permissive default; the draft's `rules.default` split was not kept | +| The full TCF purpose vocabulary is modeled | Adopted and extended: all eleven purposes are signal-resolved, and the full Privacy Taxonomy is carried as declared baseline (§2) | The joint taxonomy work made whole-taxonomy declaration the goal; `denied` defaults keep undeclared uses inert | +| Policy is an embedded file | Adopted: `permissions.yaml` is compiled into the build (§3.1); runtime configuration is deferred follow-up | The runtime push and activation pipeline does not exist; version control is the audit trail meanwhile | +| Permission sources are open-ended (#777: publisher interaction, external services may grant) | Sources are jurisdiction, policy, and the §4 signals; further sources are deferred, and the `ConsentSignal` closure is their seam (§1) | Shipping an interface with no second source repeats the inert-surface mistake; the extension seam is defined | + +## 11. Revision record vs the 2026-07-31 draft + +One row per divergence between the draft and the implementation this +revision was verified against (branch `split/5-response-hook-docs`, +PR #1045). + +| Draft position | Implemented position | Why | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Vocabulary is two TCF-purpose identifiers, enforced permissions only (§2) | IAB Privacy Taxonomy Data Uses: eleven named purposes all signal-resolved, plus 53 taxonomy Data Uses carried as declared but unenforced baseline flags | The joint taxonomy adoption postdates the draft; whole-taxonomy declaration serves completeness and demonstration, with `denied` defaults keeping unenforced flags inert | +| Policy lives in `[permissions]` in `trusted-server.toml`, published via `ts config push` (§3.1) | Policy is `permissions.yaml`, compiled into the build with `include_str!`, parsed once and covered by tests | The runtime config push and activation apparatus does not exist; publishing runtime policy without it would recreate the hazards the draft cataloged; runtime policy is deferred follow-up | +| Every group carries a required `regime` class read by auction dispatch (§3.2) | No regime field exists | Its only consumer, regime-gated dispatch, was not migrated; the field returns with that work | +| Overrides name explicit acquisition rules, replacing the `+`/`-` sigils (§3.2) | Adopted: a detailed rule's `permissions` map assigns `granted`, `requires_signal`, or `denied` per Data Use | The draft's requirement, expressed in the YAML schema; `requires_signal` is now expressible per rule | +| Two fallbacks: policy `rules.default` for unmatched countries, `default_country` only for the static no-geo mode (§5.4) | One required `[geo] default_country` covers unmatched and unresolved requests in every mode, startup-validated; a failed lookup floors separately | One deployer knob is simpler; the safety-critical separation kept is failure vs absence, carried by `GeoStatus` | +| Validation checks assigned ISO 3166-1 codes, assigned subdivisions, and a group identifier grammar (§3.3) | Validation covers unknown groups, permissions, acquisitions, revoke rules, incomplete groups, case-duplicate rule keys, and unknown fields on detailed rules | Smaller surface shipped first; the EU/EEA coverage test guards the shipped table against the typo class; ISO-assignment checks are future hardening | +| Three-class signal taxonomy with regime-scoped grant acceptance; the US posture is `requires_signal` with GPP/USP non-opt-out values as grants (§4) | TCF is the only grant source; the US posture is a `granted` baseline that opt-outs revoke; explicit non-opt-out values grant nothing | A simpler two-signal model without regimes; the cost, no-signal US traffic is allowed by baseline rather than blocked pending a signal, is a deliberate policy choice in the shipped file | +| Malformed-present blocks grants per record family and mapped section (§4.4, §4.5) | Any present-but-undecodable record revokes every Data Use for the request | Strictly more restrictive simplification; per-family scoping needs the full §4.5 decoder work | +| Normalization runs expiry before conflict resolution, a declared change (§4.4) | Conflict resolution still runs before the expiry check | The reordering was not implemented; the expired state itself (distinct from malformed, absent for acquisition) was adopted | +| Persisted-KV consent flows through the full normalization pipeline with an explicit TTL comparison (§4.4) | The loaded record substitutes directly when the request carries no signals, jurisdiction re-derived; staleness is enforced by the store TTL (`max_consent_age_days`) | The store-level TTL delivers the staleness bound without a second normalization pass | +| Proxy mode gains minimal opt-out extraction (§4.4) | Proxy mode still skips decoding; a present record blocks all grants via the malformed-present rule and the GPC header opt-out is honored without decoding | The permission-layer outcome is equally or more restrictive with no new decode paths; revisit with the §4.5 decoder work | +| Withdrawal has four triggers including an explicit storage-withdrawal or authenticated deletion request (§4.2) | The TCF Purpose 1 refusal under a non-granted baseline is the only trigger; opt-outs, malformed records, absence, and policy changes never withdraw (adopted) | No deletion endpoint exists to carry the extra trigger; the narrowest destructive surface shipped first | +| §4.3 durability protocol: family records first, suppression and authority-state records, outbox, breaker, strong reads | Cookie expiry plus best-effort identity-graph tombstones per presented identifier, with failures logged | The protocol requires storage primitives (linearizable CAS, independent durability domains) the adapters do not yet qualify; deferred with the providers-spec storage work | +| §4.5 field mapping and §4.5.1 vendored registry snapshot (sharing/targeted opt-outs, embedded GPC, applicability, derived `gpp_sid`) | Opt-out sources are the GPC header, a GPP sale opt-out, and a USP sale opt-out; the revoke set is policy-declared, shipped as `all` (which also drops storage) | The full decoder and registry vendoring are their own project; the policy-declared revoke set gives deployers the scoping lever meanwhile | +| §5.5 activation: JCS policy digests, ordinals, activation register, journal, drains, admission leases | None of it exists; the built binary is the policy identity | With no runtime policy there is nothing to activate; the draft remains the reference design for the runtime-config follow-up | +| §3.4 single jurisdiction truth, and §7 dispatch gated on the policy regime with a contextual projection | Auction dispatch keeps the consent-subsystem gate (effective TCF Purpose 1 for GDPR or unknown jurisdictions); `detect_jurisdiction` and its lists remain; no contextual view | Dispatch migration is follow-up; the legacy-list drift risk the draft named still stands and is recorded rather than resolved | +| Every raw-EC egress path is pair-gated, with per-row tests and a denylist check (§7) | Pair gating is centralized in `ec_sharing_allowed` (auction endpoint `user.id`, identify, pull sync) and `gate_eids_by_permissions` (EIDs everywhere); publisher navigation and page-bids `user.id` still ride the provider gate; batch sync checks row state only | Partial adoption; aligning the remaining paths, the S2S stored-provenance authority, and the inventory tests is recorded follow-up | +| Identity rows never store raw consent strings, only normalized provenance and a digest (§1) | The identity-graph entry stores the raw TCF and GPP strings with the row | The normalized provenance schema belongs to the providers-spec storage work; until then rows carry the raw strings | +| No signals block in policy; the signal mapping is fixed in the spec | New: a `signals` section in `permissions.yaml` declares the TCF purpose map, opt-out sources, and revoke set, with `tcf.authoritative` governing only TCF's own effect | Moves signal policy from code into deployer-editable data; the flag can never let a TCF record override an opt-out, preserving §4 precedence | +| The §5.3 no-geo guard covers every jurisdiction consumer | The guard fires when an Edge Cookie provider is configured with no geo provider | The EC provider is the only policy-gated consumer today; the trigger list grows when dispatch and further egress paths join the model | +| `default_country` is required only in the acknowledged static no-geo mode (§5.4) | Required always and startup-validated against `permissions.yaml` | It is the baseline for unmatched requests in every mode, so it must always exist | diff --git a/permissions.yaml b/permissions.yaml new file mode 100644 index 000000000..39eeba13d --- /dev/null +++ b/permissions.yaml @@ -0,0 +1,345 @@ +# Country and region permission rules for Trusted Server's permission model. +# +# This file is compiled into the build, not loaded at runtime. Editing it and +# rebuilding is how a deployer changes the default policy, so the rules stay +# visible and reviewable in version control. +# +# There are three parts: +# groups named permission baselines, defined once and referenced by rules +# rules map a country (FR) or country/state (US/CA) to a group, with optional +# explicit per-permission overrides +# signals how each session signal (TCF, US-style opt-out) maps onto Data Uses, +# so no signal-to-permission policy lives in the code +# +# Rule keys use the codes a geo provider returns, matched case-insensitively: +# country ISO 3166-1 alpha-2 (for example FR, US, GB) +# country/state adds the ISO 3166-2 subdivision code, with no country prefix +# (for example US/CA for California) +# The Fastly and other geo providers both emit these codes directly. +# +# A request whose country/region matches no rule, or whose country the geo +# provider cannot resolve, uses the deployer's default set in trusted-server.toml +# as [geo] default_country. A default is required, so there is always a default +# permission set; startup fails when none is configured. +# +# A permission flag is one of: +# granted set without any signal (for example strictly necessary) +# requires_signal set only when a signal grants it (opt-in) +# denied never set, even when a signal grants it +# +# Permission names are the IAB Privacy Taxonomy Data Uses, mapped from the IAB +# TCF Europe purposes. Every group below lists every Data Use so its policy is +# fully explicit. Two purposes have no Data Use yet: TCF purpose 1 (device +# storage) uses a proposed `necessary.operations.storage` key, and TCF purpose +# 11 (limited-data content selection) keeps its TCF identifier +# `select-basic-content`. Both are flagged for an upstream taxonomy addition. +# +# Trusted Server is not the policy authority. Where no informed policy decision +# has been made for a Data Use it is `denied`, so a demo or test deployment has +# a complete, conservative default. A deployer edits this file to set its own +# policy per jurisdiction. +# +# 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.) +groups: + # European Union: opt-in; the modeled ad-tech purposes require a signal. + gdpr-eu: + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + + # United Kingdom: device storage proceeds without a signal under the reformed ePrivacy regime. + gdpr-uk: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + + # United States: opt-out; the modeled ad-tech purposes granted by default. + us-opt-out: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + advertising_marketing.profiling: granted + advertising_marketing.first_party.targeted: granted + advertising_marketing.personalize.profiling: granted + advertising_marketing.personalize.content: granted + analytics.ad_reporting.measure_ad_performance: granted + analytics.ad_reporting.content_performance: granted + analytics.ad_reporting.market_research: granted + necessary.operations.improve: granted + select-basic-content: granted + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + +# Map a country, or a country and state (country/state), to a group. Add explicit +# per-permission overrides with +permission (granted) or -permission (denied). +# These win over the group baseline. +rules: + # European Union (27). + AT: gdpr-eu + BE: gdpr-eu + BG: gdpr-eu + HR: gdpr-eu + CY: gdpr-eu + CZ: gdpr-eu + DK: gdpr-eu + EE: gdpr-eu + FI: gdpr-eu + FR: gdpr-eu + DE: gdpr-eu + GR: gdpr-eu + HU: gdpr-eu + IE: gdpr-eu + IT: gdpr-eu + LV: gdpr-eu + LT: gdpr-eu + LU: gdpr-eu + MT: gdpr-eu + NL: gdpr-eu + PL: gdpr-eu + PT: gdpr-eu + RO: gdpr-eu + SK: gdpr-eu + SI: gdpr-eu + ES: gdpr-eu + SE: gdpr-eu + + # European Economic Area (non-EU): GDPR applies through the EEA agreement. + IS: gdpr-eu + LI: gdpr-eu + NO: gdpr-eu + + GB: gdpr-uk + AU: us-opt-out + + # United States. Every state follows the country rule until a state entry + # overrides it: a country/state key takes precedence over the country, so add + # a row only where a state's law differs from the baseline, for example: + # US/CA: + # group: us-opt-out + # permissions: + # advertising_marketing.first_party.targeted: denied + # Each permissions entry maps a Data Use to granted, requires_signal, or + # denied, overriding the group's baseline for that Data Use. Or reference a + # stricter group. + US: us-opt-out + +# 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). +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. Once the IAB Privacy + # Taxonomy tcf column is finalized (fideslang) that becomes the single source + # and this block is dropped. + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 2: advertising_marketing.first_party.contextual + 3: advertising_marketing.profiling + 4: advertising_marketing.first_party.targeted + 5: advertising_marketing.personalize.profiling + 6: advertising_marketing.personalize.content + 7: analytics.ad_reporting.measure_ad_performance + 8: analytics.ad_reporting.content_performance + 9: analytics.ad_reporting.market_research + 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. `revokes: all` drops every granted Data Use, and the map + # bounds what that actually touches. A deployer can instead list specific + # Data Uses, for example the sharing and targeted advertising uses. An + # opt-out suppresses use for the request; it is never a destructive + # withdrawal of an already-issued identifier. + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: all diff --git a/trusted-server.example.toml b/trusted-server.example.toml index f6a8226cb..fc7522ded 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -17,11 +17,24 @@ proxy_secret = "change-me-proxy-secret" # [device] # provider = "builtin" -# Geo / IP intelligence provider selection. The platform host geo is the -# default. Set provider = "none" to resolve no location and make no host geo -# call. -# [geo] +[geo] +# Geo / IP intelligence provider selection. No provider is the default: +# Trusted Server resolves no location and makes no host geo call. The host +# platform's own lookup is opt-in. # provider = "platform" +# +# The permission baseline for a request the geo provider leaves unmatched +# (and, with no geo provider, for every request). Required: startup fails +# without it. A country ("US") or country/region ("US/CA") with a rule in +# permissions.yaml. +default_country = "US" +# +# With no geo provider, every request is treated as default_country, so a +# visitor from another jurisdiction receives the default jurisdiction's +# permission rules. A deployment that runs an Edge Cookie provider without a +# geo provider must acknowledge that by uncommenting the line below, or +# select a geo provider instead. +# assume_single_jurisdiction = true [ec] # Edge Cookie identity is OFF by default: with no provider selected, Trusted