From 73b40b9c5760566a305bf9d4e8c6a716229af76e 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 slice of the PR 838 decomposition (one PR per feature, Edge Cookie provider first). Introduces the EdgeCookieProvider trait and 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 hmac block carries the passphrase that previously lived on [ec]. With no provider selected, no Edge Cookie is generated. - Built-in provider: hmac (HMAC over client IP, preserves today's identity). The host-signal provider follows in the device slice, which supplies the host fingerprints it needs. - 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. - Provider-declared identifier semantics: accepts_id gates cookie read-back and withdrawal, normalize_id_for_kv controls the KV key, so an opaque identifier survives byte for byte as both cookie value and storage key. Tests cover the verbatim round-trip with a non-default provider, evidence access at generate time, and opaque KV persistence. Edge Cookie creation and use stay gated by the existing consent context exactly as before, including with no provider selected; the permission model replaces that input in a later slice. The client-set resolve path and the response-header hook are later slices. Config migration: [ec] passphrase is rejected as an unknown field; move it to [ec.providers.hmac] and select provider = "hmac" to keep minting. --- 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 | 15 + .../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/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 | 617 +++++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 404 ++++++++++++ crates/trusted-server-core/src/edge_cookie.rs | 123 +++- crates/trusted-server-core/src/evidence.rs | 293 +++++++++ .../src/integrations/google_tag_manager.rs | 6 + .../src/integrations/prebid.rs | 3 + 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 | 221 ++++++- .../trusted-server-core/src/test_support.rs | 4 + .../configs/trusted-server.integration.toml | 5 +- .../tests/parity.rs | 3 + trusted-server.example.toml | 11 +- 30 files changed, 1964 insertions(+), 122 deletions(-) create mode 100644 crates/edgecookie/README.md create mode 100644 crates/trusted-server-core/src/ec/provider.rs create mode 100644 crates/trusted-server-core/src/evidence.rs diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..186b8c304 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,9 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in default provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. This +directory is a placeholder until a vendor provider is added. diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 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 03caa3d11..78ad1cf82 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 09e3ed324..1d6a4bb61 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 d6090c983..00757b5c1 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1284,6 +1284,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1353,6 +1356,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1717,6 +1723,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2172,6 +2181,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2297,6 +2309,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 39d35b198..dd6897cc0 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -501,6 +501,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 2c00ac2ff..e1ab28906 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -297,6 +297,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 2f7b1037e..58cbfc1b0 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 e74ef4150..fc2f693d0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -338,6 +338,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/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 d17fcd519..9e8d75f9d 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -44,6 +44,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; @@ -59,6 +60,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; @@ -69,10 +72,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; @@ -151,6 +156,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, @@ -160,6 +169,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 { @@ -199,13 +229,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| selected.accepts_id(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() @@ -222,11 +287,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, ); @@ -236,9 +310,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(), }) } @@ -264,22 +344,88 @@ 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 Some(ec_id) = generated.id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -318,6 +464,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.accepts_id(value), + ) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -347,7 +508,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 } @@ -364,6 +526,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> { @@ -382,10 +552,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,12 +572,17 @@ impl EcContext { } /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. + /// active EC denotes a different identity than the cookie value. + /// + /// The equality test is delegated to the [`EdgeCookieProvider`], because EC + /// identifiers are not assumed comparable by natural string equality: two + /// values may be different wrappers of the same payload, which only the + /// provider knows how to compare. #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { + pub fn cookie_differs_from_active_ec(&self, provider: &dyn EdgeCookieProvider) -> bool { matches!( (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active + (Some(cookie), Some(active)) if !provider.keys_equal(cookie, active) ) } @@ -414,19 +592,45 @@ impl EcContext { 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(), } } @@ -438,15 +642,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(), } } @@ -460,6 +671,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -467,9 +679,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(), } } } @@ -493,6 +711,7 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -511,6 +730,368 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A test provider that compares identifiers by the payload after a `:`, + /// modeling an envelope whose wrapper can differ for the same identity. + #[derive(Debug)] + struct WrapperInsensitiveProvider; + + impl EdgeCookieProvider for WrapperInsensitiveProvider { + fn id(&self) -> &'static str { + "wrapper-insensitive" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn keys_equal(&self, left: &str, right: &str) -> bool { + fn payload(value: &str) -> &str { + value.split_once(':').map_or(value, |(_, payload)| payload) + } + payload(left) == payload(right) + } + } + + /// A test provider that does not override `keys_equal`, so it uses the + /// default natural string equality. + #[derive(Debug)] + struct NaturalProvider; + + impl EdgeCookieProvider for NaturalProvider { + fn id(&self) -> &'static str { + "natural" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn cookie_differs_from_active_ec_delegates_to_the_provider() { + // The cookie and the active EC are different wrappers of one payload. + let context = EcContext::new_for_test_with_cookie( + Some("wrapper-active:shared-payload".to_owned()), + Some("wrapper-cookie:shared-payload".to_owned()), + true, + false, + ConsentContext::default(), + true, + ); + + assert!( + !context.cookie_differs_from_active_ec(&WrapperInsensitiveProvider), + "a payload-aware provider should treat different wrappers as the same identity" + ); + assert!( + context.cookie_differs_from_active_ec(&NaturalProvider), + "natural equality should treat different wrappers as different" + ); + } + + /// 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 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 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"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("opaque".to_owned()); + let cookie = format!("ts-ec={OPAQUE_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(OPAQUE_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + + // 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 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("evidence-ec"), + "the identifier the provider minted should be committed" + ); + } + + /// 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 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 = "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" + ); + } + #[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..42a133e47 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,404 @@ +//! 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. +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; + + /// 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 two identifiers produced by this provider denote the same + /// identity. + /// + /// Edge Cookie identifiers must not be assumed comparable by natural string + /// equality. A provider whose identifiers can carry the same payload in + /// different wrappers (for example a signed envelope that is re-issued with a + /// new timestamp or signature) overrides this to compare by payload, so the + /// system asks the provider rather than comparing the raw strings. + /// + /// The default compares the values for byte equality, which is correct for + /// providers whose identifiers are canonical, such as [`HmacProvider`]. + fn keys_equal(&self, left: &str, right: &str) -> bool { + left == right + } + + /// 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. 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 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 { + "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 id(&self) -> &'static str { + self.0.id() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn keys_equal(&self, left: &str, right: &str) -> bool { + self.0.keys_equal(left, right) + } + + 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::*; + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + /// A provider whose identifiers wrap a payload after a `:` separator, so two + /// different wrappers of the same payload denote the same identity. Stands in + /// for an envelope-based vendor identifier. + #[derive(Debug)] + struct WrappedPayloadProvider; + + impl EdgeCookieProvider for WrappedPayloadProvider { + fn id(&self) -> &'static str { + "wrapped" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn keys_equal(&self, left: &str, right: &str) -> bool { + fn payload(value: &str) -> &str { + value.split_once(':').map_or(value, |(_, payload)| payload) + } + payload(left) == payload(right) + } + } + + #[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 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 hmac_keys_equal_uses_natural_equality() { + let provider = HmacProvider::new(test_passphrase()); + assert!( + provider.keys_equal("abcd.efghij", "abcd.efghij"), + "identical HMAC keys should be equal" + ); + assert!( + !provider.keys_equal("abcd.efghij", "abcd.klmnop"), + "different HMAC keys should not be equal" + ); + } + + #[test] + fn keys_equal_can_compare_by_payload_ignoring_the_wrapper() { + let provider = WrappedPayloadProvider; + assert!( + provider.keys_equal("wrapper-1:same-payload", "wrapper-2:same-payload"), + "different wrappers of the same payload should be equal" + ); + assert!( + !provider.keys_equal("wrapper-1:payload-a", "wrapper-1:payload-b"), + "different payloads should not be equal" + ); + } + + #[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..cb82eca3e 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,33 @@ 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())?; + Ok(generated.id) } /// Gets an existing EC ID from the request. @@ -99,7 +119,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 +155,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 +166,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,9 +181,17 @@ 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"); assert_eq!( @@ -206,7 +240,9 @@ 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), @@ -214,15 +250,31 @@ mod tests { ); } + #[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" + ); + } + #[test] fn test_generate_ec_id_uses_client_ip() { 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; @@ -278,7 +330,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 +347,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 +380,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 +399,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 +425,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 4cc10f8da..c35299bab 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3021,6 +3021,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/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cfd..ba1d585d1 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -46,6 +46,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 a39a26430..b68a78184 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. @@ -178,6 +180,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 { @@ -253,6 +261,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] @@ -295,6 +314,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -308,6 +328,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + ec_provider: None, } } @@ -370,6 +391,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 @@ -402,6 +435,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 21ba9f20b..df91e86a5 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -157,6 +157,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 598c00056..40939a36c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -420,9 +420,26 @@ 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. Selecting a + /// provider whose block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: 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)] @@ -510,6 +527,116 @@ 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(()); + }; + + 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 { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })) + } + } +} + +/// 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) + } + + /// 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)] @@ -2031,6 +2158,7 @@ impl Settings { }) })?; + settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2115,8 +2243,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()); @@ -2703,9 +2833,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"); } @@ -2729,6 +2864,53 @@ 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 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 validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -3050,7 +3232,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()); @@ -3669,6 +3853,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" "#, ) @@ -3700,6 +3887,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" "#, ); @@ -4854,6 +5044,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] @@ -4997,6 +5190,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] @@ -5038,6 +5234,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] @@ -5074,6 +5273,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] @@ -5116,6 +5318,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 17d7c2713..1a1e71e81 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -10,10 +10,13 @@ origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" [ec] -passphrase = "integration-test-ec-secret-padded-32" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.providers.hmac] +passphrase = "integration-test-ec-secret-padded-32" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..853a48ee9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -43,6 +43,9 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..9cb08839f 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 849954b352d923153fb65200a6088acad8e4ca78 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 fingerprints (crates/device/fastly). - [geo] provider selects geolocation. No provider is the default and resolves no location; provider = "platform" opts into the host's lookup (crates/geo/fastly wraps the Fastly host lookup behind the PlatformGeo trait). - The host-signal Edge Cookie provider arrives with the capability it needs: the Fastly adapter injects the TLS/HTTP-2 fingerprints as a HostSignals service, and the provider mints from them plus the client IP. With no fingerprint 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 fingerprints). - 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 +++ .../trusted-server-adapter-fastly/Cargo.toml | 2 + .../trusted-server-adapter-fastly/src/app.rs | 33 +- .../trusted-server-adapter-fastly/src/main.rs | 89 ++++- .../src/platform.rs | 39 +- 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 | 144 ++++++- 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 | 74 ++++ .../src/platform/traits.rs | 7 + .../trusted-server-core/src/platform/types.rs | 35 +- crates/trusted-server-core/src/settings.rs | 190 +++++++++- .../configs/trusted-server.integration.toml | 6 + 25 files changed, 1132 insertions(+), 184 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 cb8f40c68..8ad3268b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5285,6 +5285,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5397,6 +5399,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 7ca87e687..fb4a5d976 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", @@ -107,6 +109,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-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index b6bc0f1a1..b91b99362 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -27,6 +27,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 00757b5c1..6122994b6 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -111,7 +111,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, @@ -253,15 +257,36 @@ 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)) .kv_store(Arc::clone(&state.default_kv_store)) .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() } @@ -384,7 +409,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={})", @@ -670,7 +695,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 dd6897cc0..da918135b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,13 +5,15 @@ 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::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::{ @@ -19,11 +21,13 @@ 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::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -157,7 +161,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 @@ -275,8 +314,11 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment with no geo + // provider 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 }) @@ -466,16 +508,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)] @@ -500,6 +558,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] provider = "hmac" 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-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 9e8d75f9d..a418e3e2d 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -232,9 +232,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 42a133e47..a007f4b43 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; @@ -161,23 +161,80 @@ 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 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 { @@ -189,6 +246,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. @@ -253,12 +322,33 @@ impl EdgeCookieProvider for SharedProvider { #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; use crate::redacted::Redacted; fn test_passphrase() -> Redacted { 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() + } + } + /// A provider whose identifiers wrap a payload after a `:` separator, so two /// different wrappers of the same payload denote the same identity. Stands in /// for an envelope-based vendor identifier. @@ -387,6 +477,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 { @@ -394,7 +518,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 cb82eca3e..b2665e562 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 287f1accf..75e7ec4e3 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -61,6 +61,50 @@ 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 no geo provider is configured, so a client IP is never sent +/// to any host geo service and Trusted Server stays free of a host dependency +/// for geolocation by default. 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. +/// +/// Returns [`DisabledGeo`] when no provider is selected, so a default deployment +/// makes no host geo call, the same way the Edge Cookie provider runs +/// statelessly when none is selected. 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), +/// so this falls back to [`DisabledGeo`] for that case rather than failing the +/// request. +#[must_use] +pub fn build_geo_provider( + settings: &Settings, + host_default: Arc, +) -> Arc { + match settings.geo.provider.as_deref() { + Some("platform") => host_default, + _ => Arc::new(DisabledGeo), + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr}; @@ -153,6 +197,36 @@ mod tests { assert!(result.is_none(), "should return None when no IP is present"); } + #[test] + 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 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_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 b68a78184..282ee0d6c 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. /// @@ -180,11 +181,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>, } @@ -261,6 +266,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 @@ -314,6 +331,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + host_signals: Option>, ec_provider: Option>, } @@ -328,6 +346,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + host_signals: None, ec_provider: None, } } @@ -391,6 +410,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 @@ -435,6 +465,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 40939a36c..34bb1e655 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -556,6 +556,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. @@ -593,6 +594,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 @@ -625,7 +633,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() } } @@ -639,6 +647,106 @@ pub struct HmacProviderConfig { 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)] +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)] +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)] +pub struct GeoConfig { + /// The key of the geo provider to activate. + /// + /// 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. 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, +} + +impl GeoConfig { + /// Validates that the selected geo provider is available in this build. + /// + /// No provider is valid and is the default, so the system 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") => 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 { @@ -2073,6 +2181,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 { @@ -2159,6 +2273,8 @@ impl Settings { })?; settings.ec.validate_provider_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2248,6 +2364,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()); } @@ -2897,7 +3018,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"); @@ -2911,6 +3036,61 @@ 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 geo_provider_defaults_to_no_provider_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.provider.is_none(), + "geo should default to no provider so the host geo is not used" + ); + config + .validate_provider_selection() + .expect("should allow no geo provider and run without geolocation"); + + let platform = GeoConfig { + provider: Some("platform".to_owned()), + }; + platform + .validate_provider_selection() + .expect("should validate the platform geo opt-in"); + + 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 validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -3857,6 +4037,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"); @@ -3893,9 +4074,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 1a1e71e81..67586b9b4 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" From 760b92139417970718acc0f2a9d9ccdc26bf5e16 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. - 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. With no provider selected the gate stays closed and identifiers are treated as absent, matching the stateless contract established in the first slice. - Withdrawal separates from mere not-permitted: ec_storage_withdrawn (an explicit signal) expires the cookie and writes tombstones, while a pre-consent or fail-closed state only strips EC response headers. - Bidstream EIDs gate on the permission model (gate_eids_by_permissions) instead of raw TCF consent. - [geo] default_country becomes required: it names the permissions.yaml rule that applies when the geo provider leaves a request unmatched, validated at startup, so there is always a defined permission baseline. - The consent module keeps building the ConsentContext; its EC-specific gating helpers move behind the permission model. --- CLAUDE.md | 81 +- Cargo.lock | 20 + Cargo.toml | 1 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 6 + .../trusted-server-adapter-fastly/src/app.rs | 28 +- .../src/middleware.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + crates/trusted-server-core/Cargo.toml | 1 + .../src/auction/endpoints.rs | 31 +- crates/trusted-server-core/src/config.rs | 3 + crates/trusted-server-core/src/consent/mod.rs | 669 ++------ crates/trusted-server-core/src/ec/consent.rs | 312 +++- crates/trusted-server-core/src/ec/device.rs | 18 + crates/trusted-server-core/src/ec/finalize.rs | 16 +- crates/trusted-server-core/src/ec/mod.rs | 104 +- crates/trusted-server-core/src/ec/provider.rs | 140 +- crates/trusted-server-core/src/edge_cookie.rs | 5 +- .../src/integrations/google_tag_manager.rs | 6 + .../src/integrations/prebid.rs | 8 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/permissions.rs | 1425 +++++++++++++++++ .../trusted-server-core/src/platform/mod.rs | 8 + .../src/platform/traits.rs | 8 + crates/trusted-server-core/src/publisher.rs | 6 +- .../src/response_privacy.rs | 3 + crates/trusted-server-core/src/settings.rs | 127 ++ .../trusted-server-core/src/test_support.rs | 6 + .../configs/trusted-server.integration.toml | 11 +- .../tests/parity.rs | 3 + docs/guide/permission-model.md | 278 ++++ permissions.yaml | 337 ++++ 35 files changed, 2985 insertions(+), 695 deletions(-) create mode 100644 crates/trusted-server-core/src/permissions.rs create mode 100644 docs/guide/permission-model.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 8ad3268b5..a9ebcf8cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4478,6 +4478,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" @@ -5385,6 +5398,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5556,6 +5570,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 fb4a5d976..ecff2f4e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,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/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 0009e1953..79258219e 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -139,6 +139,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .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 78ad1cf82..09993f86b 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -37,6 +37,9 @@ fn test_router() -> edgezero_core::router::RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index cb22e2126..df134c76f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -155,6 +155,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .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 1d6a4bb61..01b3406ee 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -40,6 +40,9 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); @@ -87,6 +90,9 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + default_country = "FR" + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 6122994b6..a9d2cc388 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -100,7 +100,7 @@ use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::consent::ec_storage_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; @@ -440,11 +440,14 @@ fn build_ec_request_state( }; // 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())) + && (is_real_browser || ec_storage_withdrawn(ec_context.consent())) { kv_graph.clone() } else { @@ -1211,7 +1214,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))); @@ -1314,6 +1317,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" + [geo] + default_country = "FR" + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1386,6 +1392,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1752,6 +1761,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse production-shaped settings"); @@ -2211,6 +2223,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -2339,6 +2354,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + [request_signing] enabled = false config_store_id = "test-config-store-id" diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index e1ab28906..aa2077fa6 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -296,6 +296,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 3cadf721d..9b0546ece 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -182,6 +182,9 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .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 58cbfc1b0..9dd416b51 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -39,6 +39,9 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index c035799e9..b9f35afcf 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -39,6 +39,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 c4af6fd3d..db0bfed84 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::ec::EcContext; use crate::ec::eids::{resolve_partner_ids, to_eids}; @@ -281,10 +281,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 @@ -630,13 +629,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, ) } @@ -694,7 +691,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": [ @@ -891,7 +890,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"); @@ -901,7 +900,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!( @@ -915,7 +914,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!( @@ -928,7 +927,7 @@ 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 ec_context = make_ec_context(true, None); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -942,7 +941,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 fc2f693d0..b2b4f2076 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -337,6 +337,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +default_country = "FR" + [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/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd29..d957cc797 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,311 @@ -//! 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. [`ec_permission_granted`] resolves which permissions are set for a +//! request, from its session signals and the country it maps to, and reports +//! whether every required permission is set. 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 std::sync::Arc; + +use error_stack::Report; use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::HostSignals; +use crate::permissions::{ + ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, SignalPolicy, +}; +use crate::platform::GeoInfo; +use crate::settings::Settings; + +use super::provider::build_provider; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Whether the configured Edge Cookie provider's required permissions are set +/// for this request. /// -/// 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 permission state is assembled by [`assemble_permissions`] (the +/// country/region baseline augmented by the session's signals), and this gate +/// only asks whether every permission the provider requires is set. The gate +/// never inspects consent itself: that lives in the signal mapping, so the +/// decision depends solely on the resolved permissions. A request with no Edge +/// Cookie provider configured has nothing to gate, so this returns `true`. The +/// generation path still skips when no provider is built, so no Edge Cookie is +/// written in that case. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// # Errors +/// +/// Returns [`TrustedServerError`] when the selected provider requires a service +/// the host does not supply (for example the host-signal provider on a host with +/// no fingerprints), so a misconfigured deployment fails loudly rather than +/// silently treating the permission as ungranted. +pub fn ec_permission_granted( + settings: &Settings, + consent: &ConsentContext, + geo: Option<&GeoInfo>, + host_signals: Option>, + injected: Option>, +) -> Result> { + // The provider declares the permissions its data use requires. Build it to + // read that declaration; with no provider configured there is nothing to + // gate, so the check passes. Reading `required_permissions()` needs no request + // data, so no request info is threaded here. + let Some(provider) = build_provider(&settings.ec, host_signals, injected)? else { + return Ok(true); + }; + Ok(assemble_permissions(settings, consent, geo).all_set(provider.required_permissions())) +} + +/// 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 returns no country, 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. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn assemble_permissions( + settings: &Settings, + consent: &ConsentContext, + geo: Option<&GeoInfo>, +) -> PermissionState { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = 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), + }; + maps.resolve_with( + geo.map(|info| info.country.as_str()), + geo.and_then(|info| info.region.as_deref()), + default_country, + default_region, + permission_signal(consent, maps.signals()), + ) +} + +/// 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). When the policy +/// marks TCF authoritative, a present TCF record wins and a US-style opt-out does +/// not override it. Each permission is granted when the TCF record consents to +/// the purpose the policy maps to it, and revoked when it does not. A Data Use +/// the policy maps to no purpose stays neutral, so its baseline stands. +/// +/// With no authoritative TCF record, a US-style opt-out revokes the Data Uses the +/// policy lists and anything else is neutral. 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 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, + }; + } + if opt_out_present(consent, signals.opt_out_sources()) + && signals.opt_out_revokes(permission) + { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + } } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// Whether the request carries any of the `sources` a US-style opt-out is +/// declared to use. Decoding only, so the policy (not this function) decides +/// which sources count and what the opt-out revokes. +fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { + sources.iter().any(|source| match source { + OptOutSource::Gpc => consent.gpc, + OptOutSource::GppSaleOptOut => { + consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) + } + OptOutSource::UsPrivacyOptOut => consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), + }) +} + +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. /// -/// This 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 a pre-consent or +/// fail-closed state where the permission is simply not set (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). A TCF record is authoritative where present: it is a withdrawal +/// when it refuses storage (Purpose 1). With no TCF record, a US-style storage +/// opt-out (GPC, GPP sale opt-out, or US Privacy) is a withdrawal. 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) -> bool { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + return !tcf.has_storage_consent(); + } + crate::consent::has_storage_optout_signal(consent) } #[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 ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; + fn no_edge_cookie_provider_is_vacuously_granted() { + // With no provider selected there are no required permissions to + // satisfy, so the check passes. The generation path still skips when no + // provider is built, so nothing is written to the device. + let mut settings = create_test_settings(); + settings.ec.provider = None; + assert!( + ec_permission_granted(&settings, &ConsentContext::default(), None, None, None) + .expect("the gate should evaluate without error"), + "no provider means nothing to gate, so the check passes" + ); + } + + #[test] + fn hmac_provider_is_blocked_without_storage_consent() { + // The test settings select the HMAC provider, which requires + // necessary.operations.storage. With no signal and no configured default country, + // that permission sits at the requires-signal floor, so it is not set and + // no Edge Cookie is written. + let settings = create_test_settings(); + assert!( + !ec_permission_granted(&settings, &ConsentContext::default(), None, None, None) + .expect("the gate should evaluate without error"), + "the floor should not run the HMAC provider without the permission set" + ); + } + + 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 state = assemble_permissions(&settings, &ConsentContext::default(), Some(&us_ca_geo())); assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + 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 ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + 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 state = assemble_permissions(&settings, &consent, Some(&us_ca_geo())); assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + 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 state = assemble_permissions(&settings, &consent, Some(&us_ca_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..64f0716eb 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -11,7 +11,7 @@ use http::Response; use crate::settings::Settings; use super::EcContext; -use super::consent::ec_consent_withdrawn; +use super::consent::ec_storage_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::kv::KvIdentityGraph; use super::log_id; @@ -28,7 +28,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 +67,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_storage_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -679,10 +679,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 +711,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/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index a418e3e2d..4c036eb5e 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 @@ -74,6 +74,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::PermissionState; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -156,10 +157,14 @@ 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 normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -229,9 +234,12 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - // Build the selected provider once. It is used here to decide whether - // the incoming cookie value is a usable identifier. Building it needs - // no request data, so nothing is cloned from the request. + // 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> = @@ -288,14 +296,17 @@ 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_info); + // 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={}", @@ -312,6 +323,7 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -355,7 +367,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(()); @@ -383,7 +395,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 @@ -398,6 +410,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 @@ -509,8 +522,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 } @@ -553,15 +567,26 @@ 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 } + /// 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 @@ -593,21 +618,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)] @@ -624,6 +647,7 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions: PermissionState::default(), client_ip: None, geo_info: None, device_signals: None, @@ -643,14 +667,14 @@ 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, + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, @@ -681,6 +705,7 @@ impl EcContext { ec_generated, consent, ec_allowed, + permissions: PermissionState::default(), client_ip: None, geo_info: None, device_signals: None, @@ -880,21 +905,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; @@ -997,8 +1007,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"); @@ -1064,8 +1073,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"); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index a007f4b43..f3e498b04 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 @@ -72,6 +81,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. @@ -123,6 +133,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. @@ -159,6 +180,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. @@ -213,6 +241,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, @@ -229,9 +263,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>, @@ -317,12 +350,17 @@ 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)] mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::permissions::PermissionMaps; use crate::redacted::Redacted; fn test_passphrase() -> Redacted { @@ -478,7 +516,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()), @@ -492,22 +544,43 @@ 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" + ); } #[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() { + // The wrapped-payload stub 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" + WrappedPayloadProvider.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" ); } @@ -525,4 +598,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/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index b2665e562..caf45cd20 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())?; Ok(generated.id) 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..9c3992bf4 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,9 @@ passphrase = "test-secret-key-32-bytes-minimum" enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +default_country = "FR" "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1580,6 +1583,9 @@ passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +default_country = "FR" "#; 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 c35299bab..6f140663c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1838,8 +1838,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(), @@ -3025,6 +3026,9 @@ provider = "hmac" [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +default_country = "FR" "#; /// 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 ba1d585d1..d0939f671 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -55,6 +55,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..20716daf1 --- /dev/null +++ b/crates/trusted-server-core/src/permissions.rs @@ -0,0 +1,1425 @@ +//! 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, 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. + 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 overrides the opt-out signals. + 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 overrides the opt-out signals. + 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(); + for (key, spec) in &file.rules { + let rules = match spec { + RuleSpec::Group(name) => resolve_group(&groups, name)?, + RuleSpec::Detailed { group, permissions } => { + apply_modifications(resolve_group(&groups, group)?, 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. + 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 overrides the opt-out signals. + #[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 modifications applied on top. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RuleSpec { + /// A bare group name, for example `gdpr-eu`. + Group(String), + /// A group with `+permission` / `-permission` modifications. + Detailed { + group: String, + #[serde(default)] + permissions: Vec, + }, +} + +/// 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 `+permission` (granted) and `-permission` (denied) modifications on +/// top of a group's rules, overriding the group's baseline for each. +fn apply_modifications( + mut rules: CountryRules, + modifications: &[String], +) -> Result { + for modification in modifications { + let (acquisition, name) = if let Some(name) = modification.strip_prefix('+') { + (Acquisition::Granted, name) + } else if let Some(name) = modification.strip_prefix('-') { + (Acquisition::Denied, name) + } else { + return Err(PermissionsError::InvalidModification { + value: modification.clone(), + }); + }; + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: name.to_owned(), + } + })?; + rules = rules.with_rule(permission, acquisition); + } + Ok(rules) +} + +/// An error parsing a `permissions.yaml` document. +#[derive(Debug, derive_more::Display)] +pub enum PermissionsError { + /// 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 rule modification did not start with `+` or `-`. + #[display("permission modification `{value}` must start with `+` or `-`")] + InvalidModification { 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, -advertising_marketing.first_party.contextual] +"#; + 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_modification_without_sign() { + let yaml = "groups:\n g:\n default: granted\nrules:\n US:\n group: g\n permissions: [necessary.operations.storage]\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("a modification without +/- should be rejected"); + assert!( + matches!(err, PermissionsError::InvalidModification { .. }), + "should report an invalid modification, got {err:?}" + ); + } + + #[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 75e7ec4e3..bb04315b5 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -172,6 +172,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(); 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 4bed98327..ea929fbbd 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -48,7 +48,7 @@ use crate::auction::telemetry::{ use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; -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::ec::EcContext; @@ -3190,10 +3190,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 ); } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index df91e86a5..af524fafe 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -156,6 +156,9 @@ mod tests { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "US" + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 34bb1e655..534059a56 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -724,6 +724,19 @@ pub struct GeoConfig { /// [`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, } impl GeoConfig { @@ -745,6 +758,45 @@ 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(()) + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -2275,6 +2327,7 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; + settings.geo.validate_default_country()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2284,6 +2337,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) } @@ -3077,6 +3151,7 @@ mod tests { let platform = GeoConfig { provider: Some("platform".to_owned()), + default_country: None, }; platform .validate_provider_selection() @@ -3084,6 +3159,7 @@ mod tests { let unknown = GeoConfig { provider: Some("acme".to_owned()), + default_country: None, }; assert!( unknown.validate_provider_selection().is_err(), @@ -3091,6 +3167,37 @@ 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()), + }; + 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()), + }; + assert!( + bad.validate_default_country().is_err(), + "a default country with no rule should be rejected" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -4038,6 +4145,8 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -4067,6 +4176,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + default_country = "FR" + [ec] provider = "hmac" @@ -5231,6 +5343,9 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -5371,6 +5486,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" + [ec] provider = "hmac" @@ -5415,6 +5533,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" + [ec] provider = "hmac" @@ -5454,6 +5575,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" + [ec] provider = "hmac" @@ -5499,6 +5623,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" + [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..2364a64aa 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -21,6 +21,12 @@ 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" + [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 67586b9b4..3feffccbc 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,14 @@ 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" [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..a992278f4 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -47,6 +47,9 @@ fn test_settings() -> Settings { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .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..4f29f0f89 --- /dev/null +++ b/docs/guide/permission-model.md @@ -0,0 +1,278 @@ +# 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: **`+permission`** grants it (set +without a signal) and **`-permission`** denies it (never set), each overriding +the group baseline. + +### 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 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 | 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. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Rules{Region or country
has a rule?} + Rules -- "Yes" --> CountryMap[Use that baseline] + Rules -- "No or unknown" --> Default[Use the configured default country] + 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 returns no country). +[geo] +provider = "platform" +default_country = "US" +``` + +```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 tweak its group with +grants and -denies + group: us-opt-out + permissions: [-advertising_marketing.first_party.targeted] +``` + +## 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/permissions.yaml b/permissions.yaml new file mode 100644 index 000000000..aa5afcbb7 --- /dev/null +++ b/permissions.yaml @@ -0,0 +1,337 @@ +# 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: [-select-personalised-ads] + # 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) is authoritative and overrides the opt-out signals below. Each + # listed TCF purpose grants the Data Use it maps to when the record consents to + # that purpose, and revokes it otherwise. 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 no authoritative TCF record is present. `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. + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: all