From 4ef07d51861b7367d7c050ef998bff2346cd807f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 14:20:02 +0100 Subject: [PATCH 01/82] Add the permission model with the Privacy Taxonomy vocabulary Third slice of the PR 838 decomposition. Permissions become the primitive that gates identity features; consent is one of several ways a permission is established: - permissions.rs resolves a per-request PermissionState from the country/region baseline in permissions.yaml augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the Privacy Taxonomy Data Uses. - Signal precedence is fixed in code, most restrictive first: a US-style opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses the policy revokes even when a TCF record consents, a present but undecodable record blocks baseline grants (fail-closed), and only then does a TCF record decide its mapped Data Uses. The yaml authoritative flag governs the TCF record's own grants and revokes, never whether an opt-out can be overridden. Pinning tests cover each opt-out source against a consenting TCF record. - Destructive withdrawal is narrow: only a TCF record refusing storage in a jurisdiction whose baseline did not grant it expires the cookie and writes the identity-graph tombstone. Opt-outs suppress use (headers stripped, nothing egressed) but never destroy an issued identifier, so lifting an opt-out restores the identity. - Sharing beyond the edge requires storage plus personalised-ad selection, the same pair that gates bidstream EIDs, at every egress: the auction endpoint's user.id, the publisher navigation and page-bids auction requests, the identify response, and partner pull-sync. A storage-only grant keeps first-party use while withholding partner sharing. - The Edge Cookie gate moves from raw consent to the permission model: a provider declares required_permissions() and core executes it only when every one is set. - [geo] default_country becomes required: it names the permissions.yaml rule that applies when the geo provider leaves a request unmatched. A failed geo lookup is distinct: it resolves at the requires-signal floor instead of the default, and is logged at error level. The lookup moves into EcContext::read_from_request_resolving_geo so every adapter reports the distinction identically. - Geolocation is now off by default ([geo] provider unset resolves no location); the host lookup is opt-in via provider = "platform". A deployment that runs an Edge Cookie provider with no geo provider must set [geo] assume_single_jurisdiction = true, acknowledging that every request is treated as the default jurisdiction. - permissions.yaml rules use an explicit per-permission acquisition map (granted / requires_signal / denied) instead of +/- sigils, unknown keys in a detailed rule are rejected, and two rule keys naming the same location in different case are rejected at parse. - The consent module keeps building the ConsentContext; its EC-specific gating helpers move behind the permission model. An EU-27 plus EEA coverage test locks the gdpr-eu mapping. - The design spec for this slice lives at docs/superpowers/specs/2026-07-30-permission-model-design.md, the 2026-07-31 draft revised to match this implementation with a revision-record table of every divergence. --- CLAUDE.md | 81 +- Cargo.lock | 20 + Cargo.toml | 1 + crates/trusted-server-adapter-axum/src/app.rs | 26 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 11 + .../src/app.rs | 17 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 8 + .../trusted-server-adapter-fastly/src/app.rs | 54 +- .../trusted-server-adapter-fastly/src/main.rs | 8 + .../src/middleware.rs | 4 + crates/trusted-server-adapter-spin/src/app.rs | 19 +- .../src/middleware.rs | 4 + .../tests/routes.rs | 4 + crates/trusted-server-core/Cargo.toml | 1 + .../src/auction/endpoints.rs | 51 +- crates/trusted-server-core/src/config.rs | 4 + crates/trusted-server-core/src/consent/mod.rs | 669 ++------ .../trusted-server-core/src/consent/types.rs | 15 + crates/trusted-server-core/src/ec/consent.rs | 659 +++++++- crates/trusted-server-core/src/ec/device.rs | 18 + crates/trusted-server-core/src/ec/finalize.rs | 91 +- crates/trusted-server-core/src/ec/identify.rs | 6 +- crates/trusted-server-core/src/ec/mod.rs | 277 ++- crates/trusted-server-core/src/ec/provider.rs | 135 +- .../trusted-server-core/src/ec/pull_sync.rs | 7 +- crates/trusted-server-core/src/edge_cookie.rs | 5 +- .../src/integrations/google_tag_manager.rs | 8 + .../src/integrations/prebid.rs | 9 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/permissions.rs | 1495 +++++++++++++++++ .../trusted-server-core/src/platform/mod.rs | 56 +- .../src/platform/traits.rs | 8 + crates/trusted-server-core/src/publisher.rs | 23 +- .../src/response_privacy.rs | 4 + crates/trusted-server-core/src/settings.rs | 266 ++- .../trusted-server-core/src/test_support.rs | 9 + .../configs/trusted-server.integration.toml | 15 +- .../tests/frameworks/scenarios.rs | 56 +- .../tests/parity.rs | 4 + docs/guide/permission-model.md | 303 ++++ permissions.yaml | 345 ++++ scripts/template-cache-local-test.sh | 5 + trusted-server.example.toml | 21 +- 45 files changed, 4030 insertions(+), 801 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 546a3bf526..53b50a6d28 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 3a8c33f27a..58f0c055fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4567,6 +4567,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5501,6 +5514,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5672,6 +5686,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 8e65872a95..3301e77773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" sha2 = "0.10.9" simple_logger = "5" spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index c3b7de7309..beab55749f 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -169,9 +169,13 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent), and -/// a geo lookup failure is logged and treated as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the configured +/// `[geo] default_country` baseline, while a failed lookup resolves every +/// permission at the requires-signal floor and is logged at error level. +/// The platform geo is a no-op on the local Axum dev server, so a request there +/// resolves at the default country unless it carries a signal. /// /// Mirrors the Fastly entry point, which keeps the report and answers with an /// error response: when the Edge Cookie context cannot be read the request @@ -189,14 +193,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(&state.settings, req, services) } // --------------------------------------------------------------------------- @@ -693,6 +690,13 @@ mod tests { [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because + # no geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// Builds application state directly, bypassing the composition root's diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 6a78e042d2..34924cacb5 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -197,6 +197,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index cd96dfdcfc..5d2b3b0657 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -37,6 +37,10 @@ fn test_router() -> edgezero_core::router::RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -857,6 +861,13 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because no + # geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// A provider selection this adapter can never supply must fail while the diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 395a023204..fecec85a22 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -160,8 +160,12 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Geo comes from the Workers `cf` object when deployed, and a geo lookup -/// failure is logged and treated as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the configured +/// `[geo] default_country` baseline, while a failed lookup resolves every +/// permission at the requires-signal floor and is logged at error level. +/// Geo comes from the Workers `cf` object when deployed. /// /// Mirrors the Fastly entry point, which keeps the report and answers with an /// error response: when the Edge Cookie context cannot be read the request @@ -179,14 +183,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(settings, req, services) } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 8c1aa28942..3b746db324 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -213,6 +213,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index b300e5b37d..992fca4dc2 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -40,6 +40,10 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -87,6 +91,10 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 0818f6fa1f..e9a6f93e97 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -109,7 +109,6 @@ use trusted_server_core::ec::admin::{ deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup, }; use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -486,34 +485,29 @@ fn build_ec_request_state( let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during EC setup: {e}"); - None - }); - let (ec_context, setup_error) = - match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + match EcContext::read_from_request_resolving_geo(settings, req, services) { Ok(mut context) => { context.set_device_signals(device_signals); (context, None) } Err(report) => (EcContext::default(), Some(report)), }; + let geo_info = ec_context.geo_info().cloned(); // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations keep the write path so tombstones stay - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // stays open for withdrawal so tombstones remain authoritative even for + // privacy-extension-heavy clients that do not look like known browsers. A + // merely not-permitted (pre-consent or fail-closed) request writes nothing, + // so it does not need the graph. let kv_graph = crate::maybe_identity_graph(settings); - let finalize_kv_graph = if setup_error.is_none() - && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) - { - kv_graph.clone() - } else { - None - }; + let finalize_kv_graph = + if setup_error.is_none() && (is_real_browser || ec_context.storage_withdrawn()) { + kv_graph.clone() + } else { + None + }; let kv_graph = if is_real_browser { kv_graph } else { None }; EcRequestState { @@ -1336,7 +1330,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))); @@ -1459,6 +1453,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1531,6 +1529,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1986,6 +1988,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse production-shaped settings"); @@ -2643,6 +2649,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -3061,6 +3071,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 2e16f1480f..df65113604 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -586,6 +586,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -716,6 +720,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] passphrase = "test-secret-key-32-bytes-minimum" diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 39c86ffddd..258c842a6c 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -319,6 +319,10 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 23f9322597..97b2f8bc53 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -372,9 +372,13 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the -/// request carries TCF consent, and a geo lookup failure is logged and treated -/// as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the configured +/// `[geo] default_country` baseline, while a failed lookup resolves every +/// permission at the requires-signal floor and is logged at error level. +/// Spin's platform geo is a no-op, so a request resolves at the default country +/// unless it carries a signal. /// /// Mirrors the Fastly entry point, which keeps the report and answers with an /// error response: when the Edge Cookie context cannot be read the request @@ -392,14 +396,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(settings, req, services) } fn admin_key_management_not_supported() -> Response { diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index a9698350ce..3b34002ab2 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -240,6 +240,10 @@ mod tests { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 4b315ee143..3a0757cd7f 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -39,6 +39,10 @@ fn test_router() -> RouterService { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e44d46f772..afff4c696e 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -40,6 +40,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } toml = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index fdf387e93c..86bd7b981d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -171,8 +171,9 @@ pub async fn handle_auction( // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. - // Only forward the EC ID to auction partners when consent allows it. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted: + // storage plus personalised-ad selection, the same pair that gates EIDs. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value() } else { None @@ -237,8 +238,9 @@ pub async fn handle_auction( // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. // - // Gate this on the same identity-consent condition as the EC ID - // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Gate this on the same identity condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by the sharing pair via + // `ec_context.ec_sharing_allowed()`). // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` @@ -282,10 +284,9 @@ pub async fn handle_auction( // consent gating before attaching them to the auction request. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); - auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + auction_request.user.eids = gate_eids_by_permissions(merged_eids, ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { - log::warn!("Auction EIDs stripped by TCF consent gating"); + log::warn!("Auction EIDs stripped: bidstream permissions not set"); } // Create auction context @@ -616,13 +617,11 @@ mod tests { .build() } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), - ConsentContext { - jurisdiction, - ..ConsentContext::default() - }, + ConsentContext::default(), + ec_allowed, ) } @@ -744,7 +743,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -798,7 +801,9 @@ mod tests { let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + // The default consent context keeps the jurisdiction unknown, so the + // server-side auction gate fails closed; the EC gate is off to match. + let ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -995,7 +1000,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_kv() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(None, Some(®istry), &ec_context); assert!(result.is_none(), "should return None when KV is missing"); @@ -1005,7 +1010,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_registry() { let kv = KvIdentityGraph::failing("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), None, &ec_context); assert!( @@ -1019,7 +1024,7 @@ mod tests { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1032,7 +1037,11 @@ mod tests { fn resolve_auction_eids_returns_none_when_no_ec() { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1046,7 +1055,7 @@ mod tests { let kv = KvIdentityGraph::failing("nonexistent_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); // KV store doesn't exist, so the get() call will error — should return // empty Vec (degraded mode), not None. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index ac57e069d8..cf9ee27e1e 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -469,6 +469,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +default_country = "FR" +assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a83633..ab375f0702 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -54,6 +54,7 @@ use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::permissions::{Permission, PermissionState}; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -322,7 +323,7 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b /// Returns the effective decoded TCF consent for enforcement decisions. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() @@ -475,46 +476,38 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( +pub fn gate_eids_by_permissions( eids: Option>, - consent_ctx: Option<&ConsentContext>, + permissions: &PermissionState, ) -> Option> { let eids = eids?; if eids.is_empty() { return None; } - let tcf = consent_ctx.and_then(effective_tcf); - - match tcf { - Some(tcf) if allows_eid_transmission(tcf) => Some(eids), - Some(_) => { - log::info!("EIDs stripped: TCF Purpose 1 or 4 consent missing"); - None - } - None => { - // No TCF data — if GDPR applies, block EIDs as a precaution. - if consent_ctx.is_some_and(|c| c.gdpr_applies) { - log::info!("EIDs stripped: GDPR applies but no TCF consent available"); - None - } else { - Some(eids) - } - } + if permissions.is_set(Permission::StoreOnDevice) + && permissions.is_set(Permission::SelectPersonalisedAds) + { + Some(eids) + } else { + log::info!( + "EIDs stripped: necessary.operations.storage or advertising_marketing.first_party.targeted is not set in the resolved permissions" + ); + None } } @@ -522,110 +515,30 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- -/// Determines whether Edge Cookie (EC) creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Returns `true` when the request carries a US-style storage/sale opt-out +/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of +/// jurisdiction. /// -/// The decision follows the jurisdiction's consent model: +/// This reports the signal only. Whether the opt-out changes a permission is +/// decided by the country/region map when the permission state is assembled: it +/// drops a `granted` baseline (for example a US opt-out state) and has nothing to +/// drop where the permission is `requires_signal`. Honoring it everywhere is +/// intentionally conservative. /// -/// - **GDPR (EU/UK)**: opt-in required — TCF Purpose 1 (store/access -/// information on a device) must be explicitly consented. If no TCF data is -/// available under GDPR, consent is assumed absent and EC is blocked. -/// - **US state privacy**: opt-out model — EC is allowed unless the user has -/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or -/// the US Privacy string. Explicit US opt-out signals take precedence over -/// TCF storage consent. -/// - **Non-regulated**: EC is allowed (no consent requirement). -/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is -/// blocked as a precaution. +/// TCF consent or refusal is handled separately by +/// [`crate::ec::consent::permission_signal`], which treats a present TCF record +/// as authoritative, so this helper does not consider TCF. #[must_use] -pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of other consent signals. - if ctx.gpc { - return false; - } - // Explicit US opt-out signals take precedence over TCF storage - // consent in US-state jurisdictions. - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return false; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the TCF - // Purpose 1 decision if no explicit US opt-out signal is present. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // GPP US sale_opt_out=false is an explicit non-opt-out signal. - if let Some(gpp) = &ctx.gpp - && let Some(opted_out) = gpp.us_sale_opt_out - { - return !opted_out; - } - // Check US Privacy string when no TCF decision is present. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, +pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { + if ctx.gpc { + return true; } -} - -/// Returns `true` only when the request contains an explicit EC opt-out signal. -/// -/// This is intentionally narrower than [`allows_ec_creation`]. Some requests -/// fail closed because consent cannot be verified yet (for example, missing geo -/// or missing/undecodable consent signals in a regulated jurisdiction). Those -/// cases must block *new* EC creation, but they must not be treated as an -/// authoritative withdrawal of an already-issued EC. -#[must_use] -pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent()) - } - jurisdiction::Jurisdiction::UsState(_) => { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return true; - } - if let Some(tcf) = effective_tcf(ctx) { - return !tcf.has_storage_consent(); - } - false - } - jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false, + if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { + return true; } + ctx.us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) } // --------------------------------------------------------------------------- @@ -701,9 +614,9 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, apply_expiration_check, - apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + ConsentPipelineInput, apply_expiration_check, apply_tcf_conflict_resolution, + build_consent_context, build_context_from_signals, consent_allows_server_side_auction, + gate_eids_by_permissions, has_storage_optout_signal, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -894,7 +807,7 @@ mod tests { } #[test] - fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { + fn missing_geo_keeps_unknown_jurisdiction() { let req = build_request(); let config = ConsentConfig::default(); @@ -912,10 +825,6 @@ mod tests { Jurisdiction::Unknown, "missing geo should keep jurisdiction unknown" ); - assert!( - !allows_ec_creation(&ctx), - "missing geo should keep EC creation fail-closed" - ); } #[test] @@ -1071,408 +980,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // allows_ec_creation tests - // ----------------------------------------------------------------------- - - /// Helper: builds a TCF consent with configurable Purpose 1 (storage). - fn make_tcf_with_storage(has_storage: bool) -> TcfConsent { - TcfBuilder::new().with_storage(has_storage).build() - } - - #[test] - fn ec_allowed_gdpr_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(true)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 consented should allow EC" - ); - } - - #[test] - fn ec_blocked_gdpr_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(false)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 not consented should block EC" - ); - } - - #[test] - fn ec_blocked_gdpr_no_tcf_data() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: None, - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR with no TCF data should block EC" - ); - } - - #[test] - fn ec_allowed_gdpr_via_gpp_embedded_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf_with_storage(true)), - us_sale_opt_out: None, - }), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + GPP embedded TCF with P1 consent should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_no_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + no opt-out should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + opt-out should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_implies_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPC=true with no US Privacy string should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_no_signals() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: false, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + no consent signals should block EC (spec \u{a7}6.1.1: fail-closed)" - ); - } - - #[test] - fn ec_allowed_non_regulated() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "non-regulated jurisdiction should always allow EC" - ); - } - - #[test] - fn ec_blocked_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "unknown jurisdiction should block EC (fail-closed when geo unavailable)" - ); - assert!( - !has_explicit_ec_withdrawal(&ctx), - "unknown jurisdiction should not be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC=true should block EC even when US Privacy says no opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPC=true should be treated as an explicit withdrawal signal" - ); - } - - #[test] - fn ec_us_privacy_not_applicable_allows_ec() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("VA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::NotApplicable, - opt_out_sale: PrivacyFlag::NotApplicable, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US Privacy with opt_out=N/A should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_tcf_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)" - ); - } - - #[test] - fn ec_blocked_us_state_tcf_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(false)), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + TCF Purpose 1 denied should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" - ); - } - - #[test] - fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_allowed_us_state_gpp_no_sale_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=false should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpp_sale_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpc: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when GPP US says no opt-out" - ); - } - - #[test] - fn ec_us_state_gpp_us_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPP US opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: None, - us_sale_opt_out: None, - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GPP without US section should fall through to us_privacy" - ); - } - // ----------------------------------------------------------------------- // Consent KV read-fallback / write-on-change pipeline tests // ----------------------------------------------------------------------- @@ -1629,4 +1136,86 @@ mod tests { "should not persist consent without an EC ID" ); } + + #[test] + fn gate_eids_keeps_eids_when_required_permissions_are_set() { + // US maps to us-opt-out, where necessary.operations.storage and advertising_marketing.first_party.targeted + // are granted with no signal, so bidstream EIDs are transmitted. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_some(), + "EIDs should pass when necessary.operations.storage and advertising_marketing.first_party.targeted are set" + ); + } + + #[test] + fn gate_eids_strips_eids_when_a_required_permission_is_unset() { + // FR maps to gdpr-eu, where every purpose is requires_signal, so with no + // signal neither required permission is set and EIDs are stripped. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("FR"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" + ); + } + + #[test] + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + assert!( + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" + ); + assert!( + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" + ); + } + + #[test] + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); + + let gpp_sale_opt_out = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![8], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" + ); + + let usp_opt_out = ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::No, + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage opt-out" + ); + + assert!( + !has_storage_optout_signal(&ConsentContext::default()), + "no signal is not a storage opt-out" + ); + } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index 73c2bbc3fc..2cf8a6a8e2 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -149,6 +149,21 @@ pub struct ConsentContext { } impl ConsentContext { + /// Whether any consent record is present in raw form but failed to decode. + /// + /// A malformed record is not the same as no record: the visitor expressed + /// a preference that could not be read, so the permission mapping blocks + /// baseline grants (fail-closed) instead of degrading to the no-signal + /// baseline. An expired TCF record is excluded because expiry is its own + /// explicit state ([`expired`](Self::expired)): the raw string is kept for + /// proxy forwarding while the decoded record is deliberately cleared. + #[must_use] + pub fn has_malformed_record(&self) -> bool { + (self.raw_tc_string.is_some() && self.tcf.is_none() && !self.expired) + || (self.raw_gpp_string.is_some() && self.gpp.is_none()) + || (self.raw_us_privacy.is_some() && self.us_privacy.is_none()) + } + /// Returns `true` when no consent signals are present. #[must_use] pub fn is_empty(&self) -> bool { diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd291..98b864bf3b 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,664 @@ -//! EC-specific consent gating. +//! EC-specific permission gating, resolved through the permission model. //! -//! This module provides the public consent-check API for the EC subsystem. -//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this -//! wrapper exists so that EC callers can import from `ec::consent` and the -//! eventual migration path (renaming, adding EC-specific conditions) is -//! contained here. +//! The Edge Cookie provider advertises the [`Permission`]s its data use +//! requires. [`assemble_permissions`] resolves which permissions are set for a +//! request, from its session signals and the country it maps to, and the +//! context construction gates the provider on that state. The EC permission +//! decision lives here, in the EC subsystem, and nowhere else, so callers +//! route every EC permission check through this module rather than +//! re-deriving one. use crate::consent::ConsentContext; +use crate::permissions::{ + Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, + SignalPolicy, +}; +use crate::platform::GeoInfo; +use crate::settings::Settings; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// The outcome of the geo lookup for a request, separating "no location +/// resolved" from "the lookup failed". /// -/// This is the canonical entry point for EC consent checks. It delegates -/// to [`crate::consent::allows_ec_creation`] today but may diverge as -/// EC-specific consent rules evolve. +/// The two must not collapse: with no location (the provider is disabled, or +/// had no data for the address) the deployer's `[geo] default_country` +/// baseline applies, but when the lookup errored the request's location is +/// unknown in a way the deployer default must not paper over, so every +/// permission resolves to the requires-signal floor instead. +#[derive(Debug, Clone, Copy)] +pub enum GeoStatus<'a> { + /// The provider resolved a location. + Located(&'a GeoInfo), + /// The provider resolved no location, so the configured default applies. + NoLocation, + /// The lookup errored, so the requires-signal floor applies. + Failed, +} + +impl<'a> GeoStatus<'a> { + /// The resolved location, when one exists. + #[must_use] + pub fn info(self) -> Option<&'a GeoInfo> { + match self { + GeoStatus::Located(info) => Some(info), + GeoStatus::NoLocation | GeoStatus::Failed => None, + } + } +} + +impl<'a> From> for GeoStatus<'a> { + fn from(geo: Option<&'a GeoInfo>) -> Self { + match geo { + Some(info) => GeoStatus::Located(info), + None => GeoStatus::NoLocation, + } + } +} + +/// The configured `[geo] default_country` as a [`GeoInfo`], for jurisdiction +/// detection when the geo provider resolves no location. +/// +/// The consent gates (for example the server-side auction gate) detect a +/// jurisdiction from geolocation. With no location they would resolve +/// `Unknown` and fail closed even where the deployer has declared a default +/// jurisdiction, so the same fallback the permission model applies is +/// offered here: the default country and region stand in for the missing +/// location. Only the country and region carry meaning; every other field is +/// empty. A failed lookup must not use this (see [`GeoStatus::Failed`]), so +/// the caller decides when to apply it. +#[must_use] +pub fn default_location_geo(settings: &Settings) -> Option { + let (country, region) = default_location(settings); + let country = country?; + Some(GeoInfo { + city: String::new(), + country: country.to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: region.map(str::to_owned), + asn: None, + }) +} + +/// Splits the configured `[geo] default_country` into its country and region +/// parts (`US/CA` names a country and region, `US` a bare country). +fn default_location(settings: &Settings) -> (Option<&str>, Option<&str>) { + match settings.geo.default_country.as_deref() { + Some(spec) => match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }, + None => (None, None), + } +} + +/// Assembles the permission state for a request: the country/region baseline +/// from the default maps in `permissions.yaml`, augmented by the session's +/// signals. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// Permissions exist without a consent model. With no signal present the result +/// is simply the baseline for the request's country and region. When the geo +/// provider resolves no location, or a country/region that has no rule, the +/// deployer's configured `[geo] default_country` applies. A default is required, +/// so it is always available. A failed lookup ([`GeoStatus::Failed`]) instead +/// resolves every permission to the requires-signal floor, so an outage is +/// handled protectively rather than as the deployer's default jurisdiction. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn assemble_permissions( + settings: &Settings, + consent: &ConsentContext, + geo: GeoStatus<'_>, +) -> PermissionState { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = match geo { + GeoStatus::Failed => (None, None), + GeoStatus::Located(_) | GeoStatus::NoLocation => default_location(settings), + }; + let info = geo.info(); + maps.resolve_with( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + default_country, + default_region, + permission_signal(consent, maps.signals()), + ) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// The acquisition rule for Edge Cookie storage in the request's resolved +/// jurisdiction, used to scope destructive withdrawal. /// -/// 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. +/// Resolves the same rules as [`assemble_permissions`] (the request's +/// country/region, the configured default when unmatched, and the +/// requires-signal floor when the lookup failed or nothing resolves) and +/// returns the rule for [`Permission::StoreOnDevice`]. #[must_use] -pub fn ec_consent_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +pub fn storage_acquisition(settings: &Settings, geo: GeoStatus<'_>) -> Acquisition { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = match geo { + GeoStatus::Failed => (None, None), + GeoStatus::Located(_) | GeoStatus::NoLocation => default_location(settings), + }; + let info = geo.info(); + maps.rules_or_default( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + default_country, + default_region, + ) + .map_or(Acquisition::RequiresSignal, |rules| { + rules.rule_for(Permission::StoreOnDevice) + }) +} + +/// Maps a consent context to a [`ConsentSignal`] for each permission, applying +/// the [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// +/// This is the only place the EC subsystem reads consent signals. The policy, +/// not this function, decides which sources are authoritative, which TCF purpose +/// maps to which Data Use, and what a US-style opt-out revokes. This function +/// only decodes the request and applies that policy, so no signal-to-permission +/// policy lives in the code. +/// +/// It considers every source the policy names: a TCF record (a standalone TC +/// string or the EU TCF section of a GPP string), and the US-style opt-out +/// signals (GPC, a GPP sale opt-out, or a US Privacy opt-out). Precedence is +/// most-restrictive-first and is fixed in code, not policy: +/// +/// 1. A US-style opt-out revokes the Data Uses the policy lists, even when a +/// TCF record consents. An opt-out is an explicit user signal, so no other +/// signal may override it. +/// 2. A consent record that is present but cannot be decoded revokes +/// everything, so an unreadable expression of preference fails closed +/// instead of degrading to the no-signal baseline. +/// 3. When the policy marks TCF authoritative, a present TCF record then +/// decides the mapped Data Uses: granted where the record consents to the +/// mapped purpose, revoked where it does not, and neutral where no purpose +/// is mapped. The `authoritative` flag governs only whether TCF grants and +/// revokes apply, never whether an opt-out may be overridden. +/// +/// Whether a `Revoke` changes anything is decided by the country/region map, +/// which drops a `granted` baseline and has nothing to drop where the +/// permission is `requires_signal` or `denied`. +fn permission_signal<'a>( + consent: &'a ConsentContext, + signals: &'a SignalPolicy, +) -> impl Fn(Permission) -> ConsentSignal + 'a { + move |permission| { + if opt_out_present(consent, signals.opt_out_sources()) + && signals.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + if consent.has_malformed_record() { + return ConsentSignal::Revoke; + } + if signals.tcf_authoritative() + && let Some(tcf) = crate::consent::effective_tcf(consent) + { + return match signals.tcf_purpose(permission) { + Some(purpose) => { + if tcf.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + ConsentSignal::Revoke + } + } + None => ConsentSignal::Neutral, + }; + } + ConsentSignal::Neutral + } +} + +/// Whether the request carries any of the `sources` a US-style opt-out is +/// declared to use. Decoding only, so the policy (not this function) decides +/// which sources count and what the opt-out revokes. +fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { + sources.iter().any(|source| match source { + OptOutSource::Gpc => consent.gpc, + OptOutSource::GppSaleOptOut => { + consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) + } + OptOutSource::UsPrivacyOptOut => consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), + }) +} + +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. +/// +/// This separates an affirmative withdrawal (which expires the browser cookie +/// and writes the authoritative identity-graph tombstone) from suppression, +/// where the permission is simply not set for this request (which strips EC +/// response headers but must not destroy an already-issued identifier, or a +/// returning user would be permanently withdrawn before they ever get to +/// consent). +/// +/// Only a TCF record refusing storage (Purpose 1) withdraws, and only where +/// the jurisdiction's storage baseline is not `granted`: under a +/// `requires_signal` baseline the refusal is the visitor declining the very +/// signal storage depends on, while under a `granted` baseline storage never +/// depended on the record, so the refusal suppresses use without destroying +/// the identifier. US-style opt-outs (GPC, a GPP sale opt-out, or a US +/// Privacy opt-out) suppress the permissions the policy revokes but are +/// never destructive, and no signal at all is not a withdrawal. +#[must_use] +pub fn ec_storage_withdrawn(consent: &ConsentContext, storage_baseline: Acquisition) -> bool { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + return !tcf.has_storage_consent() && !matches!(storage_baseline, Acquisition::Granted); + } + false } #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::TcfConsent; + use crate::test_support::tests::create_test_settings; + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn hmac_provider_is_blocked_without_a_storage_signal() { + // The test settings select the HMAC provider, which requires + // necessary.operations.storage. The configured default country (FR) + // resolves storage as requires-signal, so with no signal the + // permission is not set and the provider's requirement is not met. + let settings = create_test_settings(); + let provider = crate::ec::provider::build_provider(&settings.ec, None, None) + .expect("should build the configured provider") + .expect("should select the hmac provider"); + let state = + assemble_permissions(&settings, &ConsentContext::default(), GeoStatus::NoLocation); + assert!( + !state.all_set(provider.required_permissions()), + "the requires-signal default should not satisfy the HMAC provider without a signal" + ); + } + + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + + #[test] + fn no_signal_uses_the_us_opt_out_baseline() { + // US/CA maps to the us-opt-out group, where every purpose is granted + // without a signal, so EC identity and bidstream EIDs are both permitted. + let settings = create_test_settings(); + let geo = us_ca_geo(); + let state = assemble_permissions( + &settings, + &ConsentContext::default(), + GeoStatus::Located(&geo), + ); + assert!( + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "a US opt-out state should grant necessary.operations.storage and advertising_marketing.first_party.targeted" + ); + } + + #[test] + fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline with no jurisdiction match: + // the map granted these purposes, and GPC revokes them. + let settings = create_test_settings(); + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); + } + + // ------------------------------------------------------------------ + // Opt-out precedence pinning tests. These reinstate the behavior the + // consent module enforced before the permission model: an explicit + // opt-out signal suppresses storage and sharing even when a TCF record + // consents. The permission model must never let a CMP-written record + // override the visitor's own opt-out. + // ------------------------------------------------------------------ + + #[test] + fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a US Privacy opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let settings = create_test_settings(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a GPP sale opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let settings = create_test_settings(); + let consent = ConsentContext { + gpc: true, + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::No, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out source should suppress, whatever the others say" + ); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping: only a TCF storage refusal withdraws, and only + // where the baseline did not grant storage outright. Opt-outs suppress + // use but never destroy an already-issued identifier. + // ------------------------------------------------------------------ #[test] - fn ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, + fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), ..ConsentContext::default() }; + assert!( + ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "refusing the signal storage depends on should withdraw" + ); + } + #[test] + fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + !ec_storage_withdrawn(&consent, Acquisition::Granted), + "storage never depended on the record here, so refusal suppresses without destroying" ); } #[test] - fn ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), ..ConsentContext::default() }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "a consenting record is not a withdrawal" + ); + } + #[test] + fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + !ec_storage_withdrawn(&consent, Acquisition::Granted) + && !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "GPC suppresses use for the request but never destroys the identifier" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), ..ConsentContext::default() }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "sale opt-outs suppress use but never destroy the identifier" + ); + } + #[test] + fn no_signal_is_not_a_withdrawal() { + assert!( + !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), + "absence of a signal must never destroy an identifier" + ); + } + + #[test] + fn a_malformed_record_is_not_a_withdrawal() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "an unreadable record fails closed (suppression), not destructively" + ); + } + + // ------------------------------------------------------------------ + // Malformed-but-present records block baseline grants (fail closed) + // instead of degrading to the no-signal baseline. + // ------------------------------------------------------------------ + + #[test] + fn a_malformed_tcf_record_blocks_baseline_grants() { + let settings = create_test_settings(); + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + } + + #[test] + fn a_malformed_gpp_or_us_privacy_record_is_detected() { + let gpp = ConsentContext { + raw_gpp_string: Some("not-a-gpp-string".to_owned()), + ..ConsentContext::default() + }; + let usp = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; + assert!( + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" + ); + } + + #[test] + fn an_expired_tcf_record_is_not_treated_as_malformed() { + let settings = create_test_settings(); + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); + } + + // ------------------------------------------------------------------ + // Geo status: a failed lookup resolves at the requires-signal floor, + // while no location falls back to the configured default. + // ------------------------------------------------------------------ + + #[test] + fn a_failed_geo_lookup_resolves_to_the_requires_signal_floor() { + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let state = assemble_permissions(&settings, &ConsentContext::default(), GeoStatus::Failed); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a lookup failure must not fall back to the deployer default baseline" + ); + assert_eq!( + storage_acquisition(&settings, GeoStatus::Failed), + Acquisition::RequiresSignal, + "the storage baseline follows the same floor on failure" + ); + } + + #[test] + fn no_location_falls_back_to_the_configured_default() { + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let state = + assemble_permissions(&settings, &ConsentContext::default(), GeoStatus::NoLocation); + assert!( + state.is_set(Permission::StoreOnDevice), + "no location should resolve at the configured default baseline" + ); + assert_eq!( + storage_acquisition(&settings, GeoStatus::NoLocation), + Acquisition::Granted, + "the storage baseline follows the default on no location" + ); + } + + #[test] + fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // A TCF record now grants or revokes every one of the eleven mapped + // purposes, not only Purpose 1 and Purpose 4. Consent to all purposes + // except Purpose 7 (measure ad performance), in a US opt-out state where + // the baseline granted them all, so a revoke is observable as a drop. + let settings = create_test_settings(); + let consented: Vec = (1..=11).filter(|&p| p != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&settings, &consent, GeoStatus::Located(&geo)); + + // Purpose 2 is now resolved (it was neutral before), so consent sets it. + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + // Purpose 7 was refused, so the granted baseline is revoked. + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + // The originally wired purposes still behave. assert!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" ); } } diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 828fab59cf..05f8026788 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. @@ -811,6 +819,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 43707ba8b2..191bb7c84b 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -12,7 +12,6 @@ use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; -use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::kv::KvIdentityGraph; use super::log_id; @@ -22,7 +21,7 @@ use super::registry::PartnerRegistry; /// Finalizes EC response behavior for all routes. /// -/// Applies the resolved consent gate, cookie reconciliation, Prebid EID +/// Applies the resolved permission state, 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 @@ -68,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_context.storage_withdrawn() && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -477,9 +476,12 @@ mod tests { fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); + // A TCF record refusing storage is the withdrawal trigger. The test + // context resolves the storage baseline at the requires-signal floor, + // where refusing the signal storage depends on is destructive. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; @@ -530,6 +532,65 @@ mod tests { ); } + /// A decoded TCF record refusing every purpose, storage included. + fn refusing_tcf() -> crate::consent::TcfConsent { + crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { + // A US-style opt-out suppresses use (headers cleared, nothing egressed) + // but is never destructive: the browser cookie is not expired, so a + // visitor who later withdraws the opt-out keeps their identity. + let settings = create_test_settings(); + let ec_id = sample_ec_id("aBc123"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", "stale"); + + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + None, + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "x-ts-ec").is_none(), + "the opt-out should clear the EC header" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "the opt-out should not expire the browser cookie" + ); + } + #[test] fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); @@ -736,10 +797,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( @@ -768,7 +829,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" ); } @@ -778,11 +839,17 @@ mod tests { // land on the key the live row uses. Written under the raw cookie // value it creates a second row nothing reads, and the revocation // never takes effect for a provider whose canonical form differs. + // + // Destructive withdrawal is narrow, so the trigger here is a TCF record + // refusing storage, the same one + // `finalize_withdrawal_clears_cookie_and_headers` uses. An opt-out such + // as GPC suppresses use without destroying an issued identifier, so it + // would write no tombstone for this test to place. let settings = create_test_settings(); let graph = graph_with_live_canonical_row(); let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 2df83d37e1..ca34354a1e 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -61,7 +61,11 @@ pub fn handle_identify( ); }; - if !ec_context.ec_allowed() { + // Identify returns the partner's UID for this visitor, which is sharing + // the identity beyond the edge, so it needs the same permission pair as + // bidstream EIDs (storage plus personalised-ad selection), not only the + // provider's storage permission. + if !ec_context.ec_sharing_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index dcb9c09bb1..90d598e0c1 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 @@ -76,6 +76,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; +use crate::permissions::{Acquisition, Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -140,10 +141,19 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, - /// Whether Edge Cookie creation is allowed for this request. Resolved once - /// at construction: closed when no provider is selected, otherwise from the - /// request's consent context. Read via [`ec_allowed`](Self::ec_allowed). + /// Whether the configured Edge Cookie provider's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// augmented by the session's signals. Assembled once at construction and + /// read via [`permissions`](Self::permissions). + permissions: PermissionState, + /// The jurisdiction's acquisition rule for Edge Cookie storage, resolved + /// once at construction and used by + /// [`storage_withdrawn`](Self::storage_withdrawn) to scope destructive + /// withdrawal. Defaults to the requires-signal floor. + storage_acquisition: Acquisition, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -211,15 +221,71 @@ impl EcContext { services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { + Self::read_from_request_with_geo_status( + settings, + req, + services, + consent::GeoStatus::from(geo_info), + ) + } + + /// Reads the EC context, resolving the location through the configured geo + /// provider first. + /// + /// This is the constructor adapters use: it runs the geo lookup itself so + /// a failed lookup is distinguished from "no location resolved". No + /// location falls back to the configured `[geo] default_country` + /// baseline, while a failure resolves every permission to the + /// requires-signal floor (see [`consent::GeoStatus`]) and is logged at + /// error level so an outage is visible. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the selected Edge Cookie provider + /// cannot be built, the same as + /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). + pub fn read_from_request_resolving_geo( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + ) -> Result> { + let lookup = services.geo().lookup(services.client_info().client_ip); + let geo_info = match &lookup { + Ok(info) => info.clone(), + Err(error) => { + log::error!( + "geo lookup failed; resolving permissions at the requires-signal floor: {error:?}" + ); + None + } + }; + let status = match (&lookup, &geo_info) { + (Err(_), _) => consent::GeoStatus::Failed, + (Ok(_), Some(info)) => consent::GeoStatus::Located(info), + (Ok(_), None) => consent::GeoStatus::NoLocation, + }; + Self::read_from_request_with_geo_status(settings, req, services, status) + } + + fn read_from_request_with_geo_status( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo_status: consent::GeoStatus<'_>, + ) -> Result> { + let geo_info = geo_status.info(); let parsed = parse_ec_from_request(req)?; // Take the selected provider once. It is used here to decide whether - // the incoming cookie value is a usable identifier, and again by - // generation, which reuses this one rather than asking for another. - // `request_provider` hands back the instance the composition root - // resolved when there is one, and otherwise resolves the selection from - // this request's own services, the host signals among them, so a - // provider built from request evidence reads this request's evidence. + // the incoming cookie value is a usable identifier, to read the + // provider's required permissions, and again by generation, which + // reuses this one rather than asking for another. `request_provider` + // hands back the instance the composition root resolved when there is + // one, and otherwise resolves the selection from this request's own + // services, the host signals among them, so a provider built from + // request evidence reads this request's evidence. A provider that needs + // a service the host did not supply fails to resolve, which stops the + // request. let selected_provider: Option> = provider::request_provider(&settings.ec, services)?; @@ -264,24 +330,39 @@ impl EcContext { .client_ip .map(generation::normalize_ip); + // Jurisdiction detection for the consent gates follows the permission + // model's fallback: with no location resolved, the configured default + // country stands in, so a deployment that declared its jurisdiction is + // not treated as unknown. A failed lookup stays unknown, so the consent + // gates fail closed alongside the requires-signal floor. + let default_location = match geo_status { + consent::GeoStatus::NoLocation => consent::default_location_geo(settings), + consent::GeoStatus::Located(_) | consent::GeoStatus::Failed => None, + }; + let consent_geo = geo_info.or(default_location.as_ref()); + // Build consent context from request-local cookies, headers, and geo. let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), req, config: &settings.consent, - geo: geo_info, + geo: consent_geo, ec_id: None, 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 create or use an identifier, so the gate is closed rather than - // open by default. Downstream consumers read the stored result via - // [`EcContext::ec_allowed`] rather than re-deriving it. + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline augmented by the session's + // signals. Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let permissions = consent::assemble_permissions(settings, &consent, geo_status); + let storage_acquisition = consent::storage_acquisition(settings, geo_status); + // With no provider selected nothing may mint or use an identifier, so + // the gate is closed rather than open by default. let ec_allowed = selected_provider .as_ref() - .is_some_and(|_| consent::ec_consent_granted(&consent)); + .is_some_and(|selected| permissions.all_set(selected.required_permissions())); log::info!( "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", @@ -298,6 +379,8 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, + storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -356,7 +439,7 @@ impl EcContext { Jurisdiction::Unknown => "unknown", }; log::info!( - "EC generation skipped: EC creation not permitted (jurisdiction={jurisdiction})" + "EC generation skipped: required permissions not set (jurisdiction={jurisdiction})" ); return Ok(()); } @@ -380,9 +463,9 @@ impl EcContext { /// here rather than rebuilt. 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-in HMAC - /// provider reads only the client IP. The skip guards (existing EC, consent - /// gate) stay in [`generate_if_needed`](Self::generate_if_needed). + /// read cookies and request parameters at generate time, and the built-in + /// HMAC provider reads only the client IP. The skip guards (existing EC, + /// permission gate) stay in [`generate_if_needed`](Self::generate_if_needed). /// /// # Errors /// @@ -399,6 +482,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 @@ -616,8 +700,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 } @@ -660,16 +745,55 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether Edge Cookie creation is allowed for this request. + /// Returns whether the configured Edge Cookie provider's required + /// permissions are set for this request. /// - /// Resolved once at construction: closed when no provider is selected, - /// otherwise from the request's consent context (see - /// [`consent::ec_consent_granted`]). + /// Resolved once at construction through the permission model (see + /// [`consent::ec_permission_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { self.ec_allowed } + /// Whether the request carries an explicit signal withdrawing Edge Cookie + /// storage, scoped to the jurisdiction's storage baseline. + /// + /// See [`consent::ec_storage_withdrawn`]: only a TCF record refusing + /// storage withdraws, and only where the storage baseline is not + /// `granted`. Suppression (the permission merely not set) is reported by + /// [`ec_allowed`](Self::ec_allowed) being `false` instead. + #[must_use] + pub fn storage_withdrawn(&self) -> bool { + consent::ec_storage_withdrawn(&self.consent, self.storage_acquisition) + } + + /// Whether the Edge Cookie identifier may be shared beyond the edge for + /// this request: into the bidstream as `user.id`, in a partner identify + /// response, or in a partner sync call. + /// + /// Sharing rides on the same two permissions as bidstream EIDs (see + /// [`crate::consent::gate_eids_by_permissions`]): storage (the identifier + /// exists and is readable) and personalised-ad selection (it is shared to + /// select ads). [`ec_allowed`](Self::ec_allowed) covers only the + /// provider's own requirements, so a storage-only grant keeps first-party + /// use while withholding partner sharing. + #[must_use] + pub fn ec_sharing_allowed(&self) -> bool { + self.ec_allowed() + && self.permissions.is_set(Permission::StoreOnDevice) + && self.permissions.is_set(Permission::SelectPersonalisedAds) + } + + /// Returns the permissions resolved for this request. + /// + /// Assembled once at construction, the country/region baseline augmented by + /// the session's signals. The core gates provider execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions + } + /// Returns the existing EC cookie value for revocation handling. /// /// When consent is withdrawn, this value is needed to identify the @@ -702,21 +826,19 @@ impl EcContext { self } - /// 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)] @@ -726,6 +848,15 @@ impl EcContext { consent: ConsentContext, ec_allowed: bool, ) -> Self { + let permissions = if ec_allowed { + PermissionState::new( + [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] + .into_iter() + .collect(), + ) + } else { + PermissionState::default() + }; Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), @@ -733,6 +864,8 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -752,14 +885,15 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { - let ec_allowed = consent::ec_consent_granted(&consent); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, - ec_allowed, + ec_allowed: true, + storage_acquisition: Acquisition::default(), + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, @@ -790,6 +924,8 @@ impl EcContext { ec_generated, consent, ec_allowed, + permissions: PermissionState::default(), + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -934,7 +1070,13 @@ 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. + /// so the permission gate is open and generation runs. + /// + /// A test that drives a built-in provider needs this. The test default + /// country is FR, whose baseline requires a signal before + /// `StoreOnDevice` is set, so the built-in providers are gated off + /// without one. A test double declaring no required permission runs + /// either way and can read the request without a location. fn non_regulated_geo() -> GeoInfo { GeoInfo { city: String::new(), @@ -1164,8 +1306,7 @@ mod tests { // No existing cookie, so the edge creates one and persists it. 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"); @@ -1212,8 +1353,7 @@ mod tests { .expect("should build request"); let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); assert_eq!( ec.client_ip(), @@ -1302,8 +1442,7 @@ mod tests { settings.ec.provider = Some(EcProviderSelection::from("illegal")); let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); let err = ec @@ -1373,8 +1512,7 @@ mod tests { settings.ec.provider = Some(EcProviderSelection::from("header-setting")); let services = noop_services_with_ec_provider(Arc::new(provider)); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); let outcome = ec.generate_if_needed(&settings, graph).map(|()| ec); (settings, outcome) @@ -1518,8 +1656,7 @@ mod tests { let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); let graph = KvIdentityGraph::in_memory("test-ec-store"); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, Some(&graph)) .expect("should generate and persist"); @@ -1548,13 +1685,15 @@ mod tests { #[test] fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { - let settings = create_test_settings(); + let mut settings = create_test_settings(); + // A granted-baseline default jurisdiction, so the storage permission + // is set with no signal and the mint runs. + settings.geo.default_country = Some("US/CA".to_owned()); let req = create_test_request(&[]); - let geo = non_regulated_geo(); let services = crate::platform::test_support::noop_services_with_client_ip( std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), ); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, None) .expect("should generate"); @@ -1596,6 +1735,44 @@ mod tests { ); } + #[test] + fn no_location_resolves_the_consent_jurisdiction_from_the_default_country() { + use crate::consent::jurisdiction::Jurisdiction; + + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let req = create_test_request(&[]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.consent().jurisdiction, + Jurisdiction::UsState("CA".to_owned()), + "with no location the default country should decide the jurisdiction" + ); + + let mut settings = create_test_settings(); + settings.geo.default_country = Some("FR".to_owned()); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.consent().jurisdiction, + Jurisdiction::Gdpr, + "a GDPR default country should resolve the GDPR jurisdiction" + ); + } + + #[test] + fn sharing_requires_the_personalised_ads_permission_not_just_storage() { + let mut ec = + EcContext::new_for_test(Some(valid_ec_id("a", "ABC123")), ConsentContext::default()); + ec.permissions = PermissionState::new([Permission::StoreOnDevice].into_iter().collect()); + assert!(ec.ec_allowed(), "the provider gate is open"); + assert!( + !ec.ec_sharing_allowed(), + "storage alone must not allow sharing beyond the edge" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 302d846db2..f58fc1642c 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -33,6 +33,7 @@ use serde::{Deserialize, Serialize}; 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; @@ -155,16 +156,20 @@ pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER /// [`EdgeCookieProvider::generate`], not through this struct and not through /// anything injected into the provider's constructor. This struct carries only /// the per-request gating context a provider may read for behavior beyond -/// gating. On the organic request path the gate has confirmed Edge Cookie -/// storage is allowed before `generate` is called. A direct +/// gating. On the organic request path the gate has confirmed the provider's +/// required permissions are set before `generate` is called. A direct /// `edge_cookie::generate_ec_id` call, test-only today, reaches `generate` /// without that gate. #[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>, } @@ -603,9 +608,12 @@ impl<'a> AcceptedProviders<'a> { /// 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 that cannot derive an identifier at the edge returns a /// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so @@ -645,6 +653,7 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Derives an Edge Cookie identifier from the request evidence in /// `request_info` and the gating context in `input`. /// + /// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. @@ -683,6 +692,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. @@ -739,6 +759,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. @@ -803,6 +830,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) + } } /// Refuses an injected provider that claims a name core supplies itself. @@ -1126,6 +1159,10 @@ impl EdgeCookieProvider for SharedProvider { fn normalize_id_for_kv(&self, value: &str) -> String { self.0.normalize_id_for_kv(value) } + + fn required_permissions(&self) -> PermissionSet { + self.0.required_permissions() + } } #[cfg(test)] @@ -1502,6 +1539,7 @@ mod tests { "an identifier with another provider's code is never owned" ); } + use crate::permissions::PermissionMaps; use crate::redacted::Redacted; fn test_passphrase() -> Redacted { @@ -1691,7 +1729,21 @@ mod tests { } #[test] - fn host_signal_provider_creates_from_signals() { + 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()), @@ -1705,6 +1757,71 @@ mod tests { generated.id.is_some(), "the host-signal provider should create an identifier from the signals" ); + assert!( + provider + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal provider writes a cookie, so it requires necessary.operations.storage" + ); + } + + /// A minimal provider that overrides nothing optional, used to prove the + /// trait defaults. + #[derive(Debug)] + struct MinimalProvider; + + impl EdgeCookieProvider for MinimalProvider { + fn id(&self) -> &'static str { + "minimal" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0mi") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn a_neutral_provider_requires_no_permissions_by_default() { + // MinimalProvider does not override required_permissions, so it + // inherits the trait default of none and requires no permission. + assert!( + MinimalProvider.required_permissions().is_empty(), + "a vendor-neutral provider requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacProvider::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the provider's required permission is not set, so Trusted + // Server would not commit the Edge Cookie. + assert!( + !maps.resolve(None, None, |_| false).all_set(required), + "the floor should not run the Edge Cookie provider without the permission set" + ); + + // A grant signal for necessary.operations.storage: the provider's permission is now set. + assert!( + maps.resolve(None, None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie provider runs once necessary.operations.storage is set" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 6e8a2a1e33..5b2c683b4a 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -55,10 +55,13 @@ struct PullSyncResponse { /// Builds post-send pull-sync context from the route EC context. /// -/// Returns `None` when consent denies EC or there is no active EC ID. +/// Returns `None` when sharing is not permitted or there is no active EC ID. +/// Pull sync sends the identifier to a partner, so it needs the same +/// permission pair as bidstream EIDs (storage plus personalised-ad +/// selection), not only the provider's storage permission. #[must_use] pub fn build_pull_sync_context(ec_context: &EcContext) -> Option { - if !ec_context.ec_allowed() { + if !ec_context.ec_sharing_allowed() { return None; } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index b24644d972..972f9ada53 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -63,9 +63,8 @@ pub fn generate_ec_id( // host signals takes them from the injected `HostSignals` service rather // than from this request info. let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); - // The publisher path gates creation on the request's consent context at - // the call site, and the built-in provider reads neither that result nor - // the consent context, so + // The publisher path applies the permission gate at the call site, and the + // built-in provider reads neither the resolved permissions nor consent, so // they are not threaded here. let generated = provider.generate(&request_info, &IdentityInput::default())?; let generated = crate::ec::provider::GeneratedEdgeCookie { diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 4aceca9dcc..1323391c72 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1575,6 +1575,10 @@ passphrase = "test-secret-key-32-bytes-minimum" enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1609,6 +1613,10 @@ passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 47b899a136..15fbb0cd8d 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1841,8 +1841,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. + // EIDs resolved from the KV identity graph and gated on the + // resolved permission state in `handle_auction` via + // `gate_eids_by_permissions`. eids: request.user.eids.clone(), } .to_ext(), @@ -3061,6 +3062,10 @@ provider = "hmac" [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +default_country = "FR" +assume_single_jurisdiction = true "#; /// Parse a TOML string containing only the `[integrations.prebid]` section diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 3801ed9f37..31bc119aec 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod permissions; pub mod platform; pub mod price_bucket; pub mod proxy; diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs new file mode 100644 index 0000000000..5e31c31b61 --- /dev/null +++ b/crates/trusted-server-core/src/permissions.rs @@ -0,0 +1,1495 @@ +//! Provider permissions: a technical permission model gating provider execution. +//! +//! A provider (Edge Cookie, device, or geo) advertises the [`Permission`]s its +//! data use *requires*. Trusted Server resolves which permissions are currently +//! *set* from the session's signals and the country it resolves to, and refuses +//! to execute a provider whose required permissions are not set. +//! +//! The vocabulary is the IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +//! Europe purposes and used **only** as a technical identifier for a permission. +//! Two purposes have no Data Use yet: TCF purpose 1 (device storage) uses a +//! proposed `necessary.operations.storage` key and TCF purpose 11 keeps its TCF +//! identifier. No CMP or TCF *policy* is implemented here, and +//! only [`Permission::StoreOnDevice`] (TCF Purpose 1) and +//! [`Permission::SelectPersonalisedAds`] (TCF Purpose 4) are resolved against a +//! session signal today. The remaining purposes are modeled for forward +//! compatibility. +//! +//! How a permission is acquired varies by country, so resolution is keyed on the +//! ISO 3166-1 country code a geo provider returns. [`PermissionMaps::standard`] +//! loads the default country and region rules from the embedded +//! `permissions.yaml` (see `DEFAULT_PERMISSION_RULES`). +//! When no country is identified (no geo provider, or a lookup that resolves +//! nothing) or the resolved country/region has no rule, resolution uses the +//! deployer's configured default country (`[geo] default_country`). With none +//! configured, a permission is set only when the incoming signals explicitly +//! grant it. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::Deserialize; + +/// A technical permission a provider may require, labeled with its IAB Privacy +/// Taxonomy Data Use, or its IAB TCF Europe purpose where no Data Use exists yet. +/// +/// Only the identifier is used, with no TCF or taxonomy policy implemented. Only +/// [`Permission::StoreOnDevice`] (Purpose 1) and +/// [`Permission::SelectPersonalisedAds`] (Purpose 4) are resolved against a +/// signal today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Permission { + /// TCF Purpose 1, store and/or access information on a device. Resolved + /// against a session signal today. No IAB Privacy Taxonomy Data Use exists + /// for device storage yet, so this uses a proposed `necessary.operations` + /// key pending an upstream addition. + StoreOnDevice, + /// TCF Purpose 2, use limited data to select advertising. + SelectBasicAds, + /// TCF Purpose 3, create profiles for personalised advertising. + CreateAdsProfile, + /// TCF Purpose 4, use profiles to select personalised advertising. + SelectPersonalisedAds, + /// TCF Purpose 5, create profiles to personalise content. + CreateContentProfile, + /// TCF Purpose 6, use profiles to select personalised content. + SelectPersonalisedContent, + /// TCF Purpose 7, measure advertising performance. + MeasureAdPerformance, + /// TCF Purpose 8, measure content performance. + MeasureContentPerformance, + /// TCF Purpose 9, understand audiences through statistics. + MarketResearch, + /// TCF Purpose 10, develop and improve services. + DevelopServices, + /// TCF Purpose 11, use limited data to select content. No IAB Privacy + /// Taxonomy Data Use exists for limited-data content selection yet, so this + /// keeps its TCF identifier and is proposed upstream. Not gated today. + SelectBasicContent, + /// An IAB Privacy Taxonomy Data Use with no dedicated variant, identified by + /// its index into [`EXTRA_DATA_USES`]. These carry a policy flag in + /// `permissions.yaml` for completeness; no provider gates on them today. + Extra(u8), +} + +/// The Data Use identifiers for the named [`Permission`] variants, in variant +/// order (bit index 0..11). +const NAMED_DATA_USES: [&str; 11] = [ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual", + "advertising_marketing.profiling", + "advertising_marketing.first_party.targeted", + "advertising_marketing.personalize.profiling", + "advertising_marketing.personalize.content", + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.content_performance", + "analytics.ad_reporting.market_research", + "necessary.operations.improve", + "select-basic-content", +]; + +/// Every other IAB Privacy Taxonomy Data Use, carried so `permissions.yaml` can +/// set a policy flag for the whole taxonomy (bit index 11..). No provider gates +/// on these today; they exist for completeness, testing, and demonstration. +const EXTRA_DATA_USES: [&str; 53] = [ + "advertising_marketing", + "advertising_marketing.communications", + "advertising_marketing.communications.email", + "advertising_marketing.communications.sms", + "advertising_marketing.first_party", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + "advertising_marketing.personalize", + "advertising_marketing.personalize.system", + "advertising_marketing.serving", + "advertising_marketing.third_party", + "advertising_marketing.third_party.targeted", + "analytics", + "analytics.ad_reporting", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_fraud_detection", + "analytics.ad_reporting.ad_viewability", + "analytics.ad_reporting.campaign_insights", + "analytics.reporting", + "analytics.reporting.system", + "disclosure", + "disclosure.law_enforcement", + "disclosure.outside_counsel", + "disclosure.sale", + "disclosure.share", + "disclosure.third_party_sale", + "functional", + "functional.performance", + "functional.personalization", + "functional.security", + "necessary", + "necessary.employment", + "necessary.employment.hr", + "necessary.employment.hr.hiring", + "necessary.fraud_detection", + "necessary.legal_obligation", + "necessary.legal_obligation.age_verification", + "necessary.legal_obligation.content_moderation", + "necessary.legal_obligation.dsr", + "necessary.legal_obligation.hold", + "necessary.operations", + "necessary.operations.authentication", + "necessary.operations.debugging", + "necessary.operations.notifications", + "necessary.operations.notifications.email", + "necessary.operations.notifications.sms", + "necessary.operations.payment_processing", + "necessary.operations.quality_assurance", + "necessary.operations.security", + "necessary.operations.support", + "necessary.operations.survey", + "necessary.operations.upgrades", + "necessary.operations.website_use", +]; + +impl Permission { + /// The named permission variants, in bit-index order. + const NAMED: [Permission; 11] = [ + Permission::StoreOnDevice, + Permission::SelectBasicAds, + Permission::CreateAdsProfile, + Permission::SelectPersonalisedAds, + Permission::CreateContentProfile, + Permission::SelectPersonalisedContent, + Permission::MeasureAdPerformance, + Permission::MeasureContentPerformance, + Permission::MarketResearch, + Permission::DevelopServices, + Permission::SelectBasicContent, + ]; + + /// Every modeled permission: the named variants, then every other Privacy + /// Taxonomy Data Use. + pub fn all() -> impl Iterator { + Self::NAMED + .into_iter() + .chain((0..EXTRA_DATA_USES.len() as u8).map(Permission::Extra)) + } + + /// The Data Use identifier for this permission. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Permission::Extra(index) => EXTRA_DATA_USES[index as usize], + named => NAMED_DATA_USES[named.index() as usize], + } + } + + /// The stable bit position for this permission within a [`PermissionSet`]. + #[must_use] + const fn index(self) -> u8 { + match self { + Permission::StoreOnDevice => 0, + Permission::SelectBasicAds => 1, + Permission::CreateAdsProfile => 2, + Permission::SelectPersonalisedAds => 3, + Permission::CreateContentProfile => 4, + Permission::SelectPersonalisedContent => 5, + Permission::MeasureAdPerformance => 6, + Permission::MeasureContentPerformance => 7, + Permission::MarketResearch => 8, + Permission::DevelopServices => 9, + Permission::SelectBasicContent => 10, + Permission::Extra(index) => 11 + index, + } + } + + /// The single-bit mask for this permission within a [`PermissionSet`]. + const fn bit(self) -> u128 { + 1 << self.index() + } + + /// Returns the permission whose Data Use identifier matches `id` (for + /// example `"necessary.operations.storage"`), or `None` when it is unknown. + /// + /// Used to parse permission names from `permissions.yaml`. + #[must_use] + pub fn from_identifier(id: &str) -> Option { + Permission::all().find(|p| p.as_str() == id) + } +} + +impl core::fmt::Display for Permission { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A set of [`Permission`]s, stored as a bitset keyed by each permission's bit +/// index. +/// +/// Used both for what a provider requires and for what Trusted Server has set. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionSet(u128); + +impl PermissionSet { + /// The empty set, requiring or containing nothing. + #[must_use] + pub const fn none() -> Self { + Self(0) + } + + /// Returns this set with `permission` added. + #[must_use] + pub const fn with(self, permission: Permission) -> Self { + Self(self.0 | permission.bit()) + } + + /// Whether `permission` is in the set. + #[must_use] + pub const fn contains(self, permission: Permission) -> bool { + self.0 & permission.bit() != 0 + } + + /// Whether the set is empty. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every permission in `other` is also in this set. + #[must_use] + pub const fn contains_all(self, other: PermissionSet) -> bool { + self.0 & other.0 == other.0 + } + + /// Iterates the permissions in the set, in TCF purpose order. + /// + /// The built-ins read nothing from the full set; this serves a provider or + /// diagnostic path that enumerates what is present. + pub fn iter(self) -> impl Iterator { + Permission::all().filter(move |p| self.contains(*p)) + } +} + +impl FromIterator for PermissionSet { + fn from_iter>(iter: I) -> Self { + iter.into_iter() + .fold(PermissionSet::none(), PermissionSet::with) + } +} + +/// How a permission is acquired in a given country. +/// +/// This is intentionally country-keyed, not provider-keyed: a provider only +/// advertises *which* permissions it needs, and the country's rules decide *how* +/// each is obtained. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Acquisition { + /// Set without any signal, exempt or strictly necessary here. + Granted, + /// Set only when the incoming signals grant the matching TCF purpose. + /// The default, matching the floor an unresolved location falls to. + #[default] + RequiresSignal, + /// Never set in this country. + Denied, +} + +/// What a session signal says about a permission, layered on top of the +/// country/region baseline by the consent mapping. +/// +/// The core never reads consent directly. A caller maps its consent model (or +/// any other signal source) to a [`ConsentSignal`] per permission, and the +/// permission model applies it: a [`Grant`](Self::Grant) sets a +/// `RequiresSignal` permission, a [`Revoke`](Self::Revoke) drops a `Granted` one +/// (an opt-out), and [`Neutral`](Self::Neutral) leaves the baseline unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentSignal { + /// The signal grants this permission, so a `RequiresSignal` baseline is set. + Grant, + /// The signal withdraws this permission, dropping a `Granted` baseline. + Revoke, + /// The signal says nothing, so the baseline stands. + Neutral, +} + +/// The acquisition rule for each permission in one country or region. +/// +/// A `default` applies to any permission not explicitly overridden, so a rule +/// table stays compact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CountryRules { + default: Acquisition, + overrides: BTreeMap, +} + +impl CountryRules { + /// Rules with `default` for every permission and no per-permission override. + /// Groups in `permissions.yaml` are built from this plus [`with_rule`]. + /// + /// [`with_rule`]: Self::with_rule + #[must_use] + pub fn with_default(default: Acquisition) -> Self { + Self { + default, + overrides: BTreeMap::new(), + } + } + + /// Sets the acquisition rule for a single permission, overriding the default. + #[must_use] + pub fn with_rule(mut self, permission: Permission, acquisition: Acquisition) -> Self { + self.overrides.insert(permission.index(), acquisition); + self + } + + /// The acquisition rule for `permission`. + #[must_use] + pub fn rule_for(&self, permission: Permission) -> Acquisition { + self.overrides + .get(&permission.index()) + .copied() + .unwrap_or(self.default) + } +} + +/// Which Data Uses a signal revokes. +#[derive(Debug, Clone, Default)] +enum RevokeSet { + /// The signal revokes nothing. + #[default] + None, + /// The signal revokes every Data Use (the map bounds what a revoke drops). + All, + /// The signal revokes only the listed Data Uses. + Set(PermissionSet), +} + +/// How each session signal maps onto permissions, parsed from the `signals` +/// section of `permissions.yaml`. +/// +/// The permission model holds this as data so the consent mapping applies it +/// rather than encoding any signal policy in the code. It is jurisdiction-free: +/// it says only how a decoded signal grants or revokes each Data Use, and the +/// country/region baseline decides the rest. +#[derive(Debug, Clone, Default)] +pub(crate) struct SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. This never + /// lets a TCF record override an opt-out signal: an opt-out always + /// suppresses the Data Uses it revokes. + tcf_authoritative: bool, + /// Permission bit index to the TCF purpose number that grants it. + tcf_purpose: BTreeMap, + /// The signals that constitute a US-style opt-out. + opt_out_sources: Vec, + /// Which Data Uses a US-style opt-out revokes. + opt_out_revokes: RevokeSet, +} + +impl SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. + pub(crate) fn tcf_authoritative(&self) -> bool { + self.tcf_authoritative + } + + /// The TCF purpose number that grants `permission`, or `None` when no purpose + /// maps to it. + pub(crate) fn tcf_purpose(&self, permission: Permission) -> Option { + self.tcf_purpose.get(&permission.index()).copied() + } + + /// The signals that constitute a US-style opt-out. + pub(crate) fn opt_out_sources(&self) -> &[OptOutSource] { + &self.opt_out_sources + } + + /// Whether a US-style opt-out revokes `permission`. + pub(crate) fn opt_out_revokes(&self, permission: Permission) -> bool { + match &self.opt_out_revokes { + RevokeSet::None => false, + RevokeSet::All => true, + RevokeSet::Set(set) => set.contains(permission), + } + } +} + +/// Builds a validated [`SignalPolicy`] from the parsed `signals` section, +/// erroring when it names an unknown Data Use or revoke rule. +fn build_signal_policy(spec: &SignalsSpec) -> Result { + let mut policy = SignalPolicy::default(); + if let Some(tcf) = &spec.tcf { + policy.tcf_authoritative = tcf.authoritative; + for (purpose, data_use) in &tcf.purposes { + let permission = Permission::from_identifier(data_use).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: data_use.clone(), + } + })?; + policy.tcf_purpose.insert(permission.index(), *purpose); + } + } + if let Some(opt_out) = &spec.us_opt_out { + policy.opt_out_sources = opt_out.sources.clone(); + policy.opt_out_revokes = match &opt_out.revokes { + RevokeSpec::Keyword(keyword) if keyword == "all" => RevokeSet::All, + RevokeSpec::Keyword(other) => { + return Err(PermissionsError::UnknownRevoke { + value: other.clone(), + }); + } + RevokeSpec::List(names) => { + let mut set = PermissionSet::none(); + for name in names { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { name: name.clone() } + })?; + set = set.with(permission); + } + RevokeSet::Set(set) + } + }; + } + Ok(policy) +} + +/// Looks up the [`CountryRules`] for a request's country and region. +/// +/// `by_country` is keyed on the ISO 3166-1 alpha-2 code a geo provider returns +/// (upper-cased). [`PermissionMaps::standard`] populates it with a default set +/// of country rules. `by_region` keeps optional, finer rules keyed by country +/// and region (for example a US state), which take precedence over the country +/// entry. A request whose country and region match no entry resolves to `None` +/// from [`rules_for`](Self::rules_for); the caller substitutes the deployer's +/// configured default country (see [`resolve_with`](Self::resolve_with)). +#[derive(Debug, Clone, Default)] +pub struct PermissionMaps { + by_country: BTreeMap, + by_region: BTreeMap, + signals: SignalPolicy, +} + +/// The default permission rules, compiled into the build from the human-editable +/// `permissions.yaml` at the repository root. A deployer edits or replaces that +/// file to change the default policy; it is not read at runtime. +const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../permissions.yaml"); + +/// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. +fn region_key(country: &str, region: &str) -> String { + format!( + "{}:{}", + country.to_ascii_uppercase(), + region.to_ascii_uppercase() + ) +} + +impl PermissionMaps { + /// Builds an empty map set with no country or region entries. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// The signal-to-permission policy parsed from the `signals` section of + /// `permissions.yaml`. The consent mapping reads this rather than encoding + /// any signal policy in the code. + #[must_use] + pub(crate) fn signals(&self) -> &SignalPolicy { + &self.signals + } + + /// Registers explicit rules for an ISO 3166-1 alpha-2 country code. + #[must_use] + pub fn with_country(mut self, iso_code: &str, rules: CountryRules) -> Self { + self.by_country.insert(iso_code.to_ascii_uppercase(), rules); + self + } + + /// Registers explicit rules for a region within a country, keyed by the ISO + /// 3166-1 alpha-2 country and the geo provider's region code (for example + /// `US` and `CA`). + /// + /// A region entry takes precedence over the country entry, so a deployer can + /// vary a single state or province on top of the country baseline. + #[must_use] + pub fn with_region(mut self, iso_country: &str, region: &str, rules: CountryRules) -> Self { + self.by_region + .insert(region_key(iso_country, region), rules); + self + } + + /// The built-in default rules, parsed from the embedded `permissions.yaml` + /// (see `DEFAULT_PERMISSION_RULES`). + /// + /// The parse runs once per instance and the result is cached. + /// + /// # Panics + /// + /// Panics if the embedded `permissions.yaml` fails to parse. The file is a + /// build-time constant covered by tests, so a panic means the committed file + /// is malformed, not a runtime condition. + #[must_use] + pub fn standard() -> &'static Self { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + Self::from_yaml(DEFAULT_PERMISSION_RULES) + .expect("should parse the embedded default permissions.yaml") + }) + } + + /// Builds the maps from a `permissions.yaml` document: named `groups` and + /// the `rules` that map a country or country/region to a group. + /// + /// # Errors + /// + /// Returns [`PermissionsError`] when the YAML is malformed or names an + /// unknown group, permission, or acquisition rule. + pub fn from_yaml(yaml: &str) -> Result { + let file: RulesFile = + serde_yaml_ng::from_str(yaml).map_err(|error| PermissionsError::Parse { + message: error.to_string(), + })?; + + // Build every named group into its CountryRules. + let mut groups: BTreeMap = BTreeMap::new(); + for (name, flags) in &file.groups { + groups.insert(name.clone(), group_rules(name, flags)?); + } + + let mut maps = Self::empty(); + // Rule keys are matched case-insensitively at lookup, so two spellings + // of one country or region would silently overwrite each other. Reject + // the collision instead. + let mut seen_rule_keys: BTreeMap = BTreeMap::new(); + for (key, spec) in &file.rules { + if let Some(first) = seen_rule_keys.insert(key.to_ascii_uppercase(), key) { + return Err(PermissionsError::DuplicateRule { + first: first.to_owned(), + second: key.clone(), + }); + } + let rules = match spec { + RuleSpec::Group(name) => resolve_group(&groups, name)?, + RuleSpec::Detailed(detail) => apply_modifications( + resolve_group(&groups, &detail.group)?, + &detail.permissions, + )?, + }; + // A `country/region` key (for example `US/CA`) layers a region rule + // on top of its country; a bare `country` key sets the country rule. + match key.split_once('/') { + Some((country, region)) => maps = maps.with_region(country, region, rules), + None => maps = maps.with_country(key, rules), + } + } + maps.signals = build_signal_policy(&file.signals)?; + Ok(maps) + } + + /// Returns the rules that apply to `country` and `region`, preferring a + /// region entry, then the country entry, or `None` when neither matches. + #[must_use] + pub fn rules_for(&self, country: Option<&str>, region: Option<&str>) -> Option<&CountryRules> { + if let (Some(country), Some(region)) = (country, region) + && let Some(rules) = self.by_region.get(®ion_key(country, region)) + { + return Some(rules); + } + country + .map(str::to_ascii_uppercase) + .and_then(|code| self.by_country.get(&code)) + } + + /// The rules for `country`/`region`, falling back to the configured default + /// location when the request's own country and region match no rule. + /// + /// Returns `None` only when neither resolves (no default configured, or the + /// default itself has no rule), which the caller treats as the + /// requires-signal floor. In a validated deployment this is unreachable: a + /// default is required and checked at startup by + /// [`GeoConfig::validate_default_country`](crate::settings::GeoConfig::validate_default_country), + /// so a resolvable default always exists. The floor remains the behavior for + /// an unconfigured map, exercised by unit tests rather than reached at + /// runtime. + pub(crate) fn rules_or_default( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + ) -> Option<&CountryRules> { + self.rules_for(country, region) + .or_else(|| self.rules_for(default_country, default_region)) + } + + /// Resolves the permission state for a request: the country/region baseline + /// augmented by a session signal. + /// + /// `country` and `region` are what a geo provider returns (`region` may be + /// `None`). `default_country`/`default_region` are the deployer's configured + /// default location, used when the request's own country and region match no + /// rule. When neither matches (no default configured, or the default has no + /// rule) every permission is `RequiresSignal`, so nothing is set without a + /// signal. `signal` maps each permission to a [`ConsentSignal`]; the caller + /// derives it from its consent model so this module stays independent of how + /// a signal is decoded. A `Granted` baseline is set unless the signal is + /// `Revoke`, a `RequiresSignal` baseline is set only on `Grant`, and `Denied` + /// is never set. + #[must_use] + pub fn resolve_with( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + signal: impl Fn(Permission) -> ConsentSignal, + ) -> PermissionState { + let rules = self.rules_or_default(country, region, default_country, default_region); + let acquisition = + |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); + let set = Permission::all() + .filter( + |&permission| match (acquisition(permission), signal(permission)) { + (Acquisition::Denied, _) => false, + (Acquisition::Granted, ConsentSignal::Revoke) => false, + (Acquisition::Granted, _) => true, + (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, + (Acquisition::RequiresSignal, _) => false, + }, + ) + .collect(); + PermissionState { set } + } + + /// The baseline permission state for a country and region with no session + /// signal. + /// + /// Permissions exist without a consent model, so this is the set of + /// `Granted` permissions for the location (or the configured default), and is + /// what a request resolves to when no signal is present. + #[must_use] + pub fn baseline( + &self, + country: Option<&str>, + region: Option<&str>, + default_country: Option<&str>, + default_region: Option<&str>, + ) -> PermissionState { + self.resolve_with(country, region, default_country, default_region, |_| { + ConsentSignal::Neutral + }) + } + + /// Convenience over [`resolve_with`](Self::resolve_with) for a boolean + /// signal with no region and no revocation: a `true` grants the permission + /// and a `false` is neutral. + #[must_use] + pub fn resolve( + &self, + country: Option<&str>, + default_country: Option<&str>, + signal: impl Fn(Permission) -> bool, + ) -> PermissionState { + self.resolve_with(country, None, default_country, None, |permission| { + if signal(permission) { + ConsentSignal::Grant + } else { + ConsentSignal::Neutral + } + }) + } +} + +/// The permissions Trusted Server currently has set for a request. +/// +/// A provider executes only when [`all_set`](Self::all_set) of its required +/// permissions returns `true`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionState { + set: PermissionSet, +} + +impl PermissionState { + /// Builds a state in which exactly the permissions in `set` are set, for + /// tests and callers that compute the set directly. + #[must_use] + pub const fn new(set: PermissionSet) -> Self { + Self { set } + } + + /// Whether a single permission is set. + #[must_use] + pub const fn is_set(&self, permission: Permission) -> bool { + self.set.contains(permission) + } + + /// Whether every permission in `required` is set. An empty requirement is + /// always satisfied, so a provider that requires nothing always runs. + #[must_use] + pub const fn all_set(&self, required: PermissionSet) -> bool { + self.set.contains_all(required) + } + + /// The full set of permissions that are set, for a provider that adapts its + /// behavior to whatever is present. + #[must_use] + pub const fn permissions(&self) -> PermissionSet { + self.set + } +} + +// --------------------------------------------------------------------------- +// permissions.yaml parsing +// --------------------------------------------------------------------------- + +/// The shape of a `permissions.yaml` document. +#[derive(Debug, Deserialize)] +struct RulesFile { + /// Named permission baselines, keyed by group name. Each group is a flat map + /// of `default` plus optional per-permission flags. + #[serde(default)] + groups: BTreeMap>, + /// Rules keyed by country (`FR`) or country and region (`US/CA`). + #[serde(default)] + rules: BTreeMap, + /// How each session signal maps onto Data Uses. + #[serde(default)] + signals: SignalsSpec, +} + +/// The `signals` section: how each signal source maps onto Data Uses. Parsed +/// into a [`SignalPolicy`] by [`build_signal_policy`]. +#[derive(Debug, Default, Deserialize)] +struct SignalsSpec { + /// The TCF record mapping, or `None` when the file declares no TCF policy. + #[serde(default)] + tcf: Option, + /// The US-style opt-out mapping, or `None` when none is declared. + #[serde(default)] + us_opt_out: Option, +} + +/// The `signals.tcf` block. +#[derive(Debug, Deserialize)] +struct TcfSignalSpec { + /// Whether a present TCF record's grants and revokes apply. + #[serde(default = "default_true")] + authoritative: bool, + /// TCF purpose number to the Data Use it grants (and revokes when the record + /// does not consent to that purpose). + #[serde(default)] + purposes: BTreeMap, +} + +/// The `signals.us_opt_out` block. +#[derive(Debug, Deserialize)] +struct OptOutSpec { + /// The signals that constitute a US-style opt-out. + #[serde(default)] + sources: Vec, + /// Which Data Uses the opt-out revokes. + #[serde(default)] + revokes: RevokeSpec, +} + +/// A `revokes` value: the keyword `all`, or an explicit list of Data Uses. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RevokeSpec { + /// A bare keyword, expected to be `all`. + Keyword(String), + /// An explicit list of Data Use identifiers. + List(Vec), +} + +impl Default for RevokeSpec { + fn default() -> Self { + RevokeSpec::List(Vec::new()) + } +} + +/// A single US-style opt-out signal source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OptOutSource { + /// The `Sec-GPC` request header (Global Privacy Control). + Gpc, + /// A GPP US sale opt-out. + GppSaleOptOut, + /// A US Privacy string sale opt-out. + UsPrivacyOptOut, +} + +/// Default for `#[serde(default = ...)]` on a `bool` field that should be `true`. +fn default_true() -> bool { + true +} + +/// A rule entry: either a bare group name, or a group with explicit +/// per-permission acquisition overrides applied on top. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RuleSpec { + /// A bare group name, for example `gdpr-eu`. + Group(String), + /// A group with per-permission overrides. + Detailed(DetailedRuleSpec), +} + +/// A rule with a `permissions` map of Data Use to acquisition rule +/// (`granted`, `requires_signal`, or `denied`), each overriding the group's +/// baseline for that Data Use. Unknown keys are rejected so a mistyped field +/// fails loudly. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetailedRuleSpec { + group: String, + #[serde(default)] + permissions: BTreeMap, +} + +/// Resolves an acquisition rule name to its [`Acquisition`]. +fn parse_acquisition(value: &str) -> Result { + match value { + "granted" => Ok(Acquisition::Granted), + "requires_signal" => Ok(Acquisition::RequiresSignal), + "denied" => Ok(Acquisition::Denied), + other => Err(PermissionsError::UnknownAcquisition { + value: other.to_owned(), + }), + } +} + +/// Builds a group's [`CountryRules`] from its flag map. Each key names a +/// permission and its flag; an optional `default` key sets any permission the +/// group omits. A group without a `default` must list every permission, so its +/// meaning is fully explicit (this is how the shipped groups are written). +fn group_rules( + name: &str, + flags: &BTreeMap, +) -> Result { + let default = flags + .get("default") + .map(|value| parse_acquisition(value)) + .transpose()?; + // With no `default`, every permission must be listed, so this placeholder is + // never consulted once completeness is checked below. + let mut rules = CountryRules::with_default(default.unwrap_or(Acquisition::Denied)); + let mut listed = PermissionSet::none(); + for (key, value) in flags { + if key == "default" { + continue; + } + let permission = Permission::from_identifier(key) + .ok_or_else(|| PermissionsError::UnknownPermission { name: key.clone() })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + listed = listed.with(permission); + } + if default.is_none() { + for permission in Permission::all() { + if !listed.contains(permission) { + return Err(PermissionsError::IncompleteGroup { + group: name.to_owned(), + permission: permission.to_string(), + }); + } + } + } + Ok(rules) +} + +/// Looks up a group by name, erroring when a rule references a group that is not +/// defined. +fn resolve_group( + groups: &BTreeMap, + name: &str, +) -> Result { + groups + .get(name) + .cloned() + .ok_or_else(|| PermissionsError::UnknownGroup { + name: name.to_owned(), + }) +} + +/// Applies a rule's per-permission acquisition overrides on top of its group's +/// rules. Each entry maps a Data Use to `granted`, `requires_signal`, or +/// `denied`, overriding the group's baseline for that Data Use, so any +/// acquisition (not just grant or deny) is expressible per rule. +fn apply_modifications( + mut rules: CountryRules, + modifications: &BTreeMap, +) -> Result { + for (name, value) in modifications { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: name.to_owned(), + } + })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + } + Ok(rules) +} + +/// An error parsing a `permissions.yaml` document. +#[derive(Debug, derive_more::Display)] +pub enum PermissionsError { + /// Two rule keys are the same country or region spelled differently. + #[display("rule keys `{first}` and `{second}` name the same location; keep one")] + DuplicateRule { first: String, second: String }, + /// The YAML was malformed or did not match the expected shape. + #[display("failed to parse permission rules: {message}")] + Parse { message: String }, + /// A group without a `default` did not list every permission. + #[display( + "permission group `{group}` has no `default` and is missing a flag for `{permission}` (list every permission, or add a `default`)" + )] + IncompleteGroup { group: String, permission: String }, + /// A rule referenced a group that is not defined. + #[display("unknown permission group `{name}`")] + UnknownGroup { name: String }, + /// A permission flag or modification named an unknown permission. + #[display("unknown permission `{name}`")] + UnknownPermission { name: String }, + /// An acquisition rule was not `granted`, `requires_signal`, or `denied`. + #[display("unknown acquisition rule `{value}` (expected granted, requires_signal, or denied)")] + UnknownAcquisition { value: String }, + /// A `signals` opt-out `revokes` value was neither `all` nor a list. + #[display("unknown revoke rule `{value}` (expected `all` or a list of Data Uses)")] + UnknownRevoke { value: String }, +} + +impl core::error::Error for PermissionsError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_set_membership_and_subset() { + let set = PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds); + + assert!(set.contains(Permission::StoreOnDevice)); + assert!(set.contains(Permission::SelectBasicAds)); + assert!( + !set.contains(Permission::SelectPersonalisedAds), + "an absent permission should not be reported as present" + ); + + let required = PermissionSet::none().with(Permission::StoreOnDevice); + assert!(set.contains_all(required), "a subset should be contained"); + assert!( + !required.contains_all(set), + "a superset is not contained in a subset" + ); + assert!( + set.contains_all(PermissionSet::none()), + "the empty requirement is always satisfied" + ); + } + + #[test] + fn permission_set_iterates_in_bit_index_order() { + let set = PermissionSet::none() + .with(Permission::SelectBasicAds) + .with(Permission::StoreOnDevice); + let order: Vec<&str> = set.iter().map(Permission::as_str).collect(); + assert_eq!( + order, + vec![ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual" + ], + "iteration should be in stable bit-index order" + ); + } + + #[test] + fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { + // Empty maps and no default: every permission is the requires-signal + // floor, set only when a signal grants it. + let maps = PermissionMaps::default(); + + let denied = maps.resolve(Some("GB"), None, |_| false); + assert!( + !denied.is_set(Permission::StoreOnDevice), + "the floor should not set necessary.operations.storage without a signal" + ); + + let granted = maps.resolve(Some("GB"), None, |p| p == Permission::StoreOnDevice); + assert!( + granted.is_set(Permission::StoreOnDevice), + "the floor should set necessary.operations.storage once a signal grants it" + ); + } + + #[test] + fn unknown_country_uses_the_configured_default() { + // A map with a granted "us" rule, used as the default for unknown geo. + let maps = PermissionMaps::empty() + .with_country("us", CountryRules::with_default(Acquisition::Granted)); + // No country, default US: the US (granted) rule applies. + assert!( + maps.resolve(None, Some("US"), |_| false) + .is_set(Permission::StoreOnDevice), + "the configured default should set permissions when geo gives no country" + ); + // No country and no default: the requires-signal floor sets nothing. + assert!( + !maps + .resolve(None, None, |_| false) + .is_set(Permission::StoreOnDevice), + "with no default, an unknown country sets nothing without a signal" + ); + } + + #[test] + fn a_matching_country_is_used_over_the_default() { + // US grants; the default points at an opt-in "de" rule. + let maps = PermissionMaps::empty() + .with_country("us", CountryRules::with_default(Acquisition::Granted)) + .with_country( + "de", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + // US has its own rule, used directly even when a default is configured. + assert!( + maps.resolve(Some("US"), Some("DE"), |_| false) + .is_set(Permission::StoreOnDevice), + "a country with its own rule uses it, not the default" + ); + // An unmapped country falls through to the default (de, requires signal). + assert!( + !maps + .resolve(Some("ZZ"), Some("DE"), |_| false) + .is_set(Permission::StoreOnDevice), + "an unmapped country uses the default rule" + ); + } + + #[test] + fn per_permission_override_beats_the_country_default() { + // Granted by default, but deny necessary.operations.storage specifically. + let rules = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + let maps = PermissionMaps::empty().with_country("zz", rules); + let state = maps.resolve(Some("ZZ"), None, |_| true); + + assert!( + !state.is_set(Permission::StoreOnDevice), + "an explicit Denied override should beat the granted default" + ); + assert!( + state.is_set(Permission::SelectBasicAds), + "other permissions should still follow the granted default" + ); + } + + #[test] + fn all_set_gates_on_the_required_set() { + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)); + + assert!( + state.all_set(PermissionSet::none()), + "a provider requiring nothing always runs" + ); + assert!( + state.all_set(PermissionSet::none().with(Permission::StoreOnDevice)), + "a set requirement is satisfied" + ); + assert!( + !state.all_set(PermissionSet::none().with(Permission::SelectPersonalisedAds)), + "an unset requirement is not satisfied" + ); + } + + #[test] + fn with_default_sets_the_baseline_acquisition() { + // A granted default is set with no signal required. + let granted = PermissionMaps::empty() + .with_country("zz", CountryRules::with_default(Acquisition::Granted)); + assert!( + granted + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a granted default should set with no signal" + ); + // A requires-signal default is set only once a signal grants it. + let opt_in = PermissionMaps::empty().with_country( + "zz", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !opt_in + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should not be set without a signal" + ); + assert!( + opt_in + .resolve(Some("ZZ"), None, |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should set once a signal grants it" + ); + } + + #[test] + fn granted_and_denied_rules_ignore_signals() { + // A Granted rule is set even when no signal is present. + let granted = CountryRules::with_default(Acquisition::RequiresSignal) + .with_rule(Permission::StoreOnDevice, Acquisition::Granted); + assert!( + PermissionMaps::empty() + .with_country("zz", granted) + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "a Granted rule is set with no signal" + ); + + // A Denied rule is never set, even when every signal grants. + let denied = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + assert!( + !PermissionMaps::empty() + .with_country("zz", denied) + .resolve(Some("ZZ"), None, |_| true) + .is_set(Permission::StoreOnDevice), + "a Denied rule is never set even with a signal" + ); + } + + #[test] + fn standard_maps_eu_requires_signal_and_uk_grants_storage() { + let maps = PermissionMaps::standard(); + assert!( + !maps + .resolve(Some("DE"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "an EU country should not set necessary.operations.storage without a signal" + ); + assert!( + maps.resolve(Some("DE"), None, |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "an EU country should set necessary.operations.storage once a signal grants it" + ); + assert!( + maps.resolve(Some("GB"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "the UK should grant necessary.operations.storage without a signal" + ); + } + + #[test] + fn standard_maps_us_and_australia_grant_storage_by_default() { + let maps = PermissionMaps::standard(); + for code in ["US", "AU"] { + assert!( + maps.resolve(Some(code), None, |_| false) + .is_set(Permission::StoreOnDevice), + "{code} should grant necessary.operations.storage by default" + ); + } + // No default configured, so an unmapped country hits the requires-signal + // floor and sets nothing without a signal. + assert!( + !maps + .resolve(Some("ZZ"), None, |_| false) + .is_set(Permission::StoreOnDevice), + "an unmapped country with no default sets nothing without a signal" + ); + } + + #[test] + fn resolve_with_revokes_a_granted_permission_on_opt_out() { + // US grants necessary.operations.storage by default; an opt-out signal revokes it. + let maps = PermissionMaps::standard(); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::StoreOnDevice), + "the US baseline should set necessary.operations.storage" + ); + let revoked = maps.resolve_with(Some("US"), None, None, None, |p| { + if p == Permission::StoreOnDevice { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + }); + assert!( + !revoked.is_set(Permission::StoreOnDevice), + "an opt-out signal should revoke a granted permission" + ); + } + + #[test] + fn a_region_entry_overrides_the_country_baseline() { + // US grants by default; a state can require a signal instead. + let maps = PermissionMaps::standard().clone().with_region( + "US", + "CA", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !maps + .baseline(Some("US"), Some("CA"), None, None) + .is_set(Permission::StoreOnDevice), + "the CA region rule should require a signal, overriding the US baseline" + ); + assert!( + maps.baseline(Some("US"), Some("NY"), None, None) + .is_set(Permission::StoreOnDevice), + "a state with no region entry should follow the US baseline" + ); + } + + #[test] + fn from_yaml_parses_groups_rules_and_modifications() { + let yaml = r#" +groups: + eu: + default: requires_signal + us: + default: granted +rules: + FR: eu + US: us + US/CA: + group: eu + permissions: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: denied +"#; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the rules"); + + // Bare group references. + assert!( + !maps + .baseline(Some("FR"), None, None, None) + .is_set(Permission::StoreOnDevice), + "FR (eu) requires a signal for device storage" + ); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::StoreOnDevice), + "US (us) grants device storage" + ); + + // CA references the eu group, but +necessary.operations.storage grants it, overriding + // the eu baseline. + assert!( + maps.baseline(Some("US"), Some("CA"), None, None) + .is_set(Permission::StoreOnDevice), + "+necessary.operations.storage grants it for CA, overriding the eu baseline" + ); + // -advertising_marketing.first_party.contextual denies it: not set even when a signal grants it. + assert!( + !maps + .resolve_with(Some("US"), Some("CA"), None, None, |_| ConsentSignal::Grant) + .is_set(Permission::SelectBasicAds), + "-advertising_marketing.first_party.contextual denies it even when a signal grants it" + ); + + // An unmapped country with a default of `US` uses the us (granted) rule. + assert!( + maps.baseline(Some("ZZ"), None, Some("US"), None) + .is_set(Permission::StoreOnDevice), + "an unmapped country uses the configured default (us, granted)" + ); + // With no default, an unmapped country hits the requires-signal floor. + assert!( + !maps + .baseline(Some("ZZ"), None, None, None) + .is_set(Permission::StoreOnDevice), + "with no default, an unmapped country sets nothing" + ); + } + + #[test] + fn from_yaml_rejects_unknown_group() { + let err = PermissionMaps::from_yaml("groups: {}\nrules:\n US: nope\n") + .expect_err("a rule naming an undefined group should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownGroup { .. }), + "should report an unknown group, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_an_incomplete_group_without_default() { + // A group with no `default` must list every permission, so this one + // (only necessary.operations.storage) is rejected rather than silently leaving the + // other ten unset. + let yaml = "groups:\n g:\n necessary.operations.storage: granted\nrules: {}\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an incomplete group without a default should be rejected"); + assert!( + matches!(err, PermissionsError::IncompleteGroup { .. }), + "should report an incomplete group, got {err:?}" + ); + } + + #[test] + fn from_yaml_accepts_an_explicit_group_listing_every_permission() { + // The shipped style: no `default`, every permission spelled out. + let mut group = String::from("groups:\n everything:\n"); + for permission in Permission::all() { + group.push_str(&format!(" {permission}: granted\n")); + } + let yaml = format!("{group}rules:\n US: everything\n"); + let maps = PermissionMaps::from_yaml(&yaml).expect("an explicit group should parse"); + assert!( + maps.baseline(Some("US"), None, None, None) + .is_set(Permission::MarketResearch), + "every listed permission should take its flag" + ); + } + + #[test] + fn from_yaml_rejects_unknown_permission() { + let yaml = "groups:\n g:\n default: granted\n not-a-permission: denied\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown permission should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownPermission { .. }), + "should report an unknown permission, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_unknown_acquisition() { + let yaml = "groups:\n g:\n default: maybe\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown acquisition should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_a_non_acquisition_override_value() { + let yaml = "groups:\n g:\n default: granted\nrules:\n US:\n group: g\n permissions:\n necessary.operations.storage: enabled\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown acquisition value should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_duplicate_rule_keys_differing_only_by_case() { + let yaml = "groups:\n g:\n default: granted\nrules:\n us: g\n US: g\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("two spellings of one country should be rejected"); + assert!( + matches!(err, PermissionsError::DuplicateRule { .. }), + "should report the duplicate rule, got {err:?}" + ); + } + + #[test] + fn a_detailed_rule_can_set_requires_signal_per_permission() { + let yaml = "groups:\n g:\n default: granted\nrules:\n US:\n group: g\n permissions:\n necessary.operations.storage: requires_signal\n"; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the override map"); + let rules = maps + .rules_for(Some("US"), None) + .expect("should resolve the US rule"); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "the per-permission map should express requires_signal" + ); + assert_eq!( + rules.rule_for(Permission::SelectPersonalisedAds), + Acquisition::Granted, + "an unlisted permission should keep the group default" + ); + } + + #[test] + fn every_eu_and_eea_member_requires_a_signal_for_storage() { + // The shipped permissions.yaml must cover all 27 EU member states and + // the three EEA members, each resolving storage as requires-signal, so + // no member state silently falls to the deployer default. + let maps = PermissionMaps::standard(); + for country in [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", + "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", + "LI", "NO", + ] { + let rules = maps + .rules_for(Some(country), None) + .unwrap_or_else(|| panic!("`{country}` should have a rule")); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "storage in `{country}` should require a signal" + ); + } + } + + #[test] + fn from_yaml_parses_the_signals_section_into_a_policy() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + FR: g +signals: + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 4: advertising_marketing.first_party.targeted + us_opt_out: + sources: [gpc] + revokes: [advertising_marketing.first_party.targeted] +"; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the signals section"); + let signals = maps.signals(); + assert!(signals.tcf_authoritative(), "tcf should be authoritative"); + assert_eq!( + signals.tcf_purpose(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + signals.tcf_purpose(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + assert_eq!( + signals.tcf_purpose(Permission::CreateAdsProfile), + None, + "an unmapped Data Use has no purpose" + ); + assert!( + signals.opt_out_revokes(Permission::SelectPersonalisedAds), + "a listed Data Use is revoked by the opt-out" + ); + assert!( + !signals.opt_out_revokes(Permission::StoreOnDevice), + "an unlisted Data Use is not revoked by the opt-out" + ); + } + + #[test] + fn from_yaml_rejects_an_unknown_revoke_keyword() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + FR: g +signals: + us_opt_out: + sources: [gpc] + revokes: everything +"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown revoke keyword should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownRevoke { .. }), + "should report an unknown revoke rule, got {err:?}" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1bb2005554..3b6283e450 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -85,10 +85,12 @@ use crate::settings::Settings; /// A geo provider that resolves nothing. /// -/// Installed when `[geo] provider = "none"` is selected, so a client IP is -/// never sent to any host geo service. Every geo consumer already treats -/// [`GeoInfo`] as optional, so a `None` result degrades gracefully (the -/// jurisdiction is unknown, the auction omits geo, and so on). +/// Installed when no geo provider is selected (`"none"` spells the same +/// choice explicitly), so a client IP is never sent to any host geo service +/// and a default deployment is not tied to any host geo capability. Every geo +/// consumer already treats [`GeoInfo`] as optional, so a `None` result +/// degrades gracefully (the permission baseline falls back to the configured +/// `[geo] default_country`, the auction omits geo, and so on). pub struct DisabledGeo; impl PlatformGeo for DisabledGeo { @@ -99,12 +101,12 @@ impl PlatformGeo for DisabledGeo { /// Selects the geo provider named by the `[geo] provider` selector. /// -/// The host platform's geo lookup is the default: with no selector, -/// `host_default` (the adapter's platform geo implementation) resolves the -/// location, matching the behavior before the selector existed, and -/// `provider = "platform"` spells the same choice explicitly. Selecting -/// `provider = "none"` returns [`DisabledGeo`] instead, so a client IP is -/// never sent to any host geo service. A selected-but-unknown provider is +/// Returns [`DisabledGeo`] when no provider is selected, so a default +/// deployment makes no host geo call and the permission baseline comes from +/// the configured `[geo] default_country`; `provider = "none"` spells the +/// same choice explicitly. The host platform's own geo lookup is opt-in: +/// `provider = "platform"` returns `host_default`, which the adapter passes +/// as its platform geo implementation. A selected-but-unknown provider is /// rejected at startup by /// [`GeoConfig::validate_provider_selection`](crate::settings::GeoConfig::validate_provider_selection). #[must_use] @@ -113,8 +115,8 @@ pub fn build_geo_provider( host_default: Arc, ) -> Arc { match settings.geo.provider.as_deref() { - Some("none") => Arc::new(DisabledGeo), - _ => host_default, + Some("platform") => host_default, + _ => Arc::new(DisabledGeo), } } @@ -185,6 +187,14 @@ mod tests { { } + #[test] + fn disabled_geo_requires_no_permissions() { + assert!( + DisabledGeo.required_permissions().is_empty(), + "the default disabled geo provider requires no permissions" + ); + } + #[test] fn runtime_services_can_be_constructed_and_cloned() { let services = noop_services(); @@ -211,18 +221,25 @@ mod tests { } #[test] - fn build_geo_provider_defaults_to_the_host_geo() { + fn build_geo_provider_defaults_to_no_geo() { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_provider(&settings, Arc::clone(&host)); assert!( - Arc::ptr_eq(&host, &selected), - "default settings should use the host geo" + !Arc::ptr_eq(&host, &selected), + "default settings should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the default geo provider should resolve nothing" ); } #[test] - fn build_geo_provider_none_selects_no_geo() { + fn build_geo_provider_none_selects_no_geo_explicitly() { let mut settings = Settings::default(); settings.geo.provider = Some("none".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); @@ -231,13 +248,6 @@ mod tests { !Arc::ptr_eq(&host, &selected), "provider none should not use the host geo" ); - assert!( - selected - .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) - .expect("disabled geo lookup should not fail") - .is_none(), - "the disabled geo provider should resolve nothing" - ); } #[test] diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index 6bceabd7cc..4a60954030 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 40bb7ea277..67b29d1295 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -54,7 +54,7 @@ use crate::auction::types::{ use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::creative_opportunities::{AssemblyMode, CreativeOpportunitiesConfig}; @@ -4078,12 +4078,18 @@ pub async fn handle_publisher_request( // this handler; subresource requests are likewise filtered there. let ec_allowed = ec_context.ec_allowed(); log::debug!( - "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}", + "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}, sharing={}", ec_context.ec_value().is_some(), + ec_context.ec_sharing_allowed(), ); let consent_context = ec_context.consent().clone(); - let ec_id = ec_context.ec_value().filter(|_| ec_allowed); + // The identifier forwarded into the auction request (user.id) is sharing + // beyond the edge, so it rides the same permission pair as bidstream EIDs + // (storage plus personalised-ad selection), not only the provider's gate. + let ec_id = ec_context + .ec_value() + .filter(|_| ec_context.ec_sharing_allowed()); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); @@ -4961,10 +4967,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 ); } @@ -6404,7 +6410,12 @@ pub async fn handle_page_bids( }; let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); - let ec_id = ec_context.ec_value().filter(|_| ec_context.ec_allowed()); + // Same sharing pair as the navigation path: page-bids builds an auction + // request, so its user.id egress needs storage plus personalised-ad + // selection, matching the EID gate. + let ec_id = ec_context + .ec_value() + .filter(|_| ec_context.ec_sharing_allowed()); let consent_context = ec_context.consent(); let geo = ec_context.geo_info().cloned(); let cookie_jar = handle_request_cookies(&req)?; diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index d2a413a714..63c15fc978 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -218,6 +218,10 @@ mod tests { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "US" + assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 03c3e421af..100f81b8b7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -901,26 +901,58 @@ impl DeviceConfig { pub struct GeoConfig { /// The key of the geo provider to activate. /// - /// The host platform's geo lookup is the default when absent, matching the - /// behavior before this selector existed; `provider = "platform"` spells - /// the same choice explicitly. Selecting `provider = "none"` resolves no - /// geolocation and makes no host geo call, so a deployment can opt out of - /// any host geo service. Override it with the + /// No provider is the default: Trusted Server resolves no geolocation and + /// makes no host geo call, so a default deployment is not tied to any host + /// geo service, and the permission baseline comes from + /// [`default_country`](Self::default_country). `provider = "none"` spells + /// the same choice explicitly. The host platform's own geo lookup is + /// opt-in via `provider = "platform"`. Override it with the /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled /// WebAssembly can switch providers at deployment. An unknown key is rejected /// at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). #[serde(default, skip_serializing_if = "Option::is_none")] 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). + /// + /// Because it is required, any deployment running the permission model + /// serializes a `[geo]` section, so its pushed config blob is rejected by a + /// binary that predates this section. Rolling back to one needs a compatible + /// blob restored first, the same trade-off a configured + /// [`trusted_client_ip`](Settings::trusted_client_ip) carries. + #[serde(default)] + pub default_country: Option, + + /// Acknowledges that, with no geo provider, every request is treated as + /// coming from [`default_country`](Self::default_country). + /// + /// With geolocation off, a visitor from any other jurisdiction silently + /// receives the default jurisdiction's permission rules. A deployment that + /// runs an Edge Cookie provider without a geo provider must set this to + /// `true`, checked at startup by + /// [`validate_jurisdiction_acknowledgment`](Self::validate_jurisdiction_acknowledgment), + /// so serving a single jurisdiction is an explicit operator decision rather + /// than an accident of the default configuration. + #[serde(default)] + pub assume_single_jurisdiction: bool, } impl GeoConfig { /// Validates that the selected geo provider is available in this build. /// - /// No selector is valid and is the default, selecting the host platform's - /// geo lookup. The explicit `"none"` runs without geolocation, the same - /// way the Edge Cookie provider runs statelessly when `"none"` is - /// selected. + /// No selector is valid and is the default, running without geolocation, + /// the same way the Edge Cookie provider runs statelessly when none is + /// selected. The explicit `"none"` spells the same choice. /// /// # Errors /// @@ -934,6 +966,83 @@ impl GeoConfig { })), } } + + /// Validates that `default_country` is set and resolves to a rule in the + /// compiled `permissions.yaml`. + /// + /// A default is required: it is the permission baseline for a request the geo + /// provider leaves unmatched, so there must always be one. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when `default_country` is + /// unset, or is set but matches no country or country/region rule. + pub fn validate_default_country(&self) -> Result<(), Report> { + let Some(spec) = self.default_country.as_deref() else { + return Err(Report::new(TrustedServerError::Configuration { + message: "[geo] default_country is required. There must always be a default \ + permission set, so set the country (`US`) or country/region \ + (`US/CA`) whose permissions.yaml rule applies to a request the geo \ + provider leaves unmatched" + .to_owned(), + })); + }; + let (country, region) = match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }; + if crate::permissions::PermissionMaps::standard() + .rules_for(country, region) + .is_none() + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[geo] default_country `{spec}` matches no rule in permissions.yaml \ + (use a country code like `US` or country/region like `US/CA` that \ + has a rule)" + ), + })); + } + Ok(()) + } + + /// Validates that running jurisdiction consumers without geolocation is + /// explicitly acknowledged. + /// + /// With no geo provider, every request resolves to the + /// [`default_country`](Self::default_country) permission baseline, so a + /// visitor from any other jurisdiction silently receives the default + /// jurisdiction's rules. That is acceptable only as an explicit operator + /// decision. When an Edge Cookie provider is configured (the permission + /// model gates it by jurisdiction) and no geo provider is selected, + /// [`assume_single_jurisdiction`](Self::assume_single_jurisdiction) must be + /// `true`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when an Edge Cookie + /// provider is configured, no geo provider is selected, and + /// `assume_single_jurisdiction` is not set. + pub fn validate_jurisdiction_acknowledgment( + &self, + ec: &Ec, + ) -> Result<(), Report> { + let geo_disabled = !matches!(self.provider.as_deref(), Some("platform")); + // The selector is a typed enum on this branch, so statelessness is the + // absent selector or the explicit `none`, matched rather than compared + // as a string. + let ec_active = !matches!(ec.provider, None | Some(EcProviderSelection::None)); + if geo_disabled && ec_active && !self.assume_single_jurisdiction { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider is configured but no [geo] provider is selected, so \ + every request would be treated as [geo] default_country. Set \ + [geo] assume_single_jurisdiction = true to acknowledge \ + single-jurisdiction operation, or select a geo provider" + .to_owned(), + })); + } + Ok(()) + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -3307,6 +3416,10 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; + settings.geo.validate_default_country()?; + settings + .geo + .validate_jurisdiction_acknowledgment(&settings.ec)?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3316,6 +3429,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) } @@ -4059,9 +4193,18 @@ mod tests { #[test] fn serialized_default_config_stays_readable_by_the_base_revision_schema() { - let settings = Settings::from_toml(&crate_test_settings_str()) + let mut settings = Settings::from_toml(&crate_test_settings_str()) .expect("should parse settings without trusted client IP configuration"); + // The guarantee covers a config that configures none of the sections + // added since the base revision. A configured section is serialized and, + // like a configured `trusted_client_ip`, needs a compatible blob restored + // before rolling back to a binary that predates it. The shared test + // config sets `[geo]` because the permission model requires a default + // country, so both selector tables are reset to unset here. + settings.geo = GeoConfig::default(); + settings.device = DeviceConfig::default(); + let value = serde_json::to_value(&settings).expect("should serialize settings"); serde_json::from_value::(value) @@ -5502,14 +5645,16 @@ mod tests { let config = GeoConfig::default(); assert!( config.provider.is_none(), - "geo should default to no selector, which selects the host geo" + "geo should default to no selector, which selects no geo provider" ); config .validate_provider_selection() - .expect("should validate the default host geo selection"); + .expect("should validate the default of running without geolocation"); let platform = GeoConfig { provider: Some("platform".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; platform .validate_provider_selection() @@ -5517,12 +5662,16 @@ mod tests { let none = GeoConfig { provider: Some("none".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; none.validate_provider_selection() .expect("should validate the explicit opt-out of geolocation"); let unknown = GeoConfig { provider: Some("acme".to_owned()), + default_country: None, + assume_single_jurisdiction: false, }; assert!( unknown.validate_provider_selection().is_err(), @@ -5558,6 +5707,72 @@ mod tests { ); } + #[test] + fn validate_default_country_checks_against_permissions_yaml() { + // Unset is rejected: a default permission set is required, so startup + // fails without one. + assert!( + GeoConfig::default().validate_default_country().is_err(), + "an unset default country should be rejected" + ); + + // A country, and a country/region, that have a rule are accepted. + for spec in ["US", "FR", "US/CA"] { + let config = GeoConfig { + provider: None, + default_country: Some(spec.to_owned()), + assume_single_jurisdiction: false, + }; + config + .validate_default_country() + .unwrap_or_else(|e| panic!("default `{spec}` should validate, got {e:?}")); + } + + // A code with no rule is rejected at startup. + let bad = GeoConfig { + provider: None, + default_country: Some("ZZ".to_owned()), + assume_single_jurisdiction: false, + }; + assert!( + bad.validate_default_country().is_err(), + "a default country with no rule should be rejected" + ); + } + + #[test] + fn ec_without_geo_requires_the_single_jurisdiction_acknowledgment() { + // The base test settings acknowledge single-jurisdiction operation. + // Removing the acknowledgment while an EC provider is configured and + // no geo provider is selected must fail at startup. + let toml_str = crate_test_settings_str().replace("assume_single_jurisdiction = true\n", ""); + let err = Settings::from_toml(&toml_str) + .expect_err("an EC provider with no geo provider needs the acknowledgment"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + + // Selecting a geo provider removes the requirement. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("[geo]", "[geo]\n provider = \"platform\""); + Settings::from_toml(&toml_str) + .expect("a geo provider resolves jurisdictions, so no acknowledgment is needed"); + + // With no EC provider there is no jurisdiction consumer to protect. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("provider = \"hmac\"", "") + .replace("[ec.providers.hmac]\n passphrase = \"test-secret-key-32-bytes-minimum\"", ""); + Settings::from_toml(&toml_str) + .expect("stateless operation needs no jurisdiction acknowledgment"); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -6598,6 +6813,9 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -6627,6 +6845,10 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -7791,6 +8013,10 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -8121,6 +8347,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -8208,6 +8438,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -8247,6 +8481,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" @@ -8292,6 +8530,10 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +default_country = "US" +assume_single_jurisdiction = true + [ec] provider = "hmac" diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index f755a0bcd5..a49417cb81 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -21,6 +21,15 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + # A gdpr-eu country, where every permission requires a signal. This + # reproduces the prior no-default floor, so existing tests are + # unaffected by the now-required default. + default_country = "FR" + # Tests run with no geo provider, so single-jurisdiction operation + # is acknowledged the same way a deployment would. + assume_single_jurisdiction = true + [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 497205d4c1..b98733dff4 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,18 @@ 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 suppressing use of the +# identifier (identify answers 403) without expiring the ts-ec cookie, since +# opt-outs are never destructive. [geo] -provider = "platform" +default_country = "US/CA" +# Viceroy runs without a geo provider, so every request is the default +# jurisdiction by design. +assume_single_jurisdiction = true [ec] provider = "hmac" diff --git a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs index c1bbafe8df..a7a45f2f63 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs @@ -445,9 +445,10 @@ pub enum EcScenario { /// returns the scoped UID. FullLifecycle, - /// Consent withdrawal: GPC header triggers EC cookie deletion for a - /// seeded EC in the default US-state test geo. - ConsentWithdrawal, + /// Opt-out suppression: a GPC header suppresses use of a seeded EC + /// (identify answers 403) without expiring the cookie, in the default + /// US-state test geo. Opt-outs are never destructive. + OptOutSuppression, /// Identify without EC cookie returns 204. IdentifyWithoutEc, @@ -470,7 +471,7 @@ impl EcScenario { pub fn all() -> Vec { vec![ Self::FullLifecycle, - Self::ConsentWithdrawal, + Self::OptOutSuppression, Self::IdentifyWithoutEc, Self::IdentifyConsentDenied, Self::ConcurrentPartnerSyncs, @@ -489,7 +490,7 @@ impl EcScenario { pub fn run(&self, base_url: &str) -> TestResult<()> { match self { Self::FullLifecycle => ec_full_lifecycle(base_url), - Self::ConsentWithdrawal => ec_consent_withdrawal(base_url), + Self::OptOutSuppression => ec_opt_out_suppression(base_url), Self::IdentifyWithoutEc => ec_identify_without_ec(base_url), Self::IdentifyConsentDenied => ec_identify_consent_denied(base_url), Self::ConcurrentPartnerSyncs => ec_concurrent_partner_syncs(base_url), @@ -577,37 +578,48 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { } /// Consent withdrawal: GPC header clears EC cookie. -fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { +fn ec_opt_out_suppression(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); let seeded_ec_id = seeded_ec_id('b', "test02"); let ec_id = use_seeded_ec(&client, &seeded_ec_id); - log::info!("EC consent withdrawal: using seeded EC = {ec_id}"); + log::info!("EC opt-out suppression: using seeded EC = {ec_id}"); - // GPC overrides the allow cookie in US-CA, so this is an explicit - // withdrawal and must expire the EC cookie. + // GPC is a US-style opt-out. It suppresses use of the identifier for the + // request but is never destructive: the cookie is not expired and no + // tombstone is written, so lifting the opt-out restores the identity. let resp = client.get_with_headers("/", &[("sec-gpc", "1")])?; - - if !is_ec_cookie_expired(&resp) { + if is_ec_cookie_expired(&resp) { return Err(Report::new(TestError::UnexpectedContent) - .attach("consent withdrawal should expire ts-ec cookie (expected Max-Age=0)")); + .attach("an opt-out must suppress without expiring the ts-ec cookie")); } - if client.ec_cookie_value().is_some() { + if client.ec_cookie_value().is_none() { return Err(Report::new(TestError::UnexpectedContent) - .attach("client should stop tracking ts-ec after explicit withdrawal")); + .attach("the client should keep tracking ts-ec through an opt-out")); } - // 3. With consent still granted and the EC cookie revoked, identify should - // now report no EC present. - let resp = identify(&client, INTTEST_API_TOKEN)?; - assert_status(&resp, 204).attach("identify should return 204 after cookie revocation")?; - - // 4. With GPC still asserted, identify should reflect consent denial. + // With GPC asserted, identify reflects the suppressed permissions. let resp = identify_with_headers(&client, INTTEST_API_TOKEN, &[("sec-gpc", "1")])?; assert_status(&resp, 403) - .attach("identify with GPC should return 403 after consent withdrawal")?; + .attach("identify with GPC should return 403 while the opt-out is asserted")?; + + // Without GPC the identity is usable again: the cookie still carries the + // identifier, so identify answers 200 with consent ok. No row was seeded + // for this identifier, so there is no enrichment, and the absence of a + // 403 proves the opt-out destroyed nothing. + let resp = identify(&client, INTTEST_API_TOKEN)?; + let body = assert_json_response(resp, 200)?; + if body.get("consent").and_then(|v| v.as_str()) != Some("ok") { + return Err(Report::new(TestError::UnexpectedContent).attach(format!( + "identify without GPC should report consent ok, got {body}" + ))); + } + if body.get("uid").is_some() { + return Err(Report::new(TestError::UnexpectedContent) + .attach("no partner UID was seeded, so identify should carry no enrichment")); + } - log::info!("EC consent withdrawal: PASSED"); + log::info!("EC opt-out suppression: PASSED"); Ok(()) } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 853a48ee9d..3459fd5c0a 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -47,6 +47,10 @@ fn test_settings() -> Settings { [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#, ) .expect("should parse parity test settings") diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md new file mode 100644 index 0000000000..0e89e5b593 --- /dev/null +++ b/docs/guide/permission-model.md @@ -0,0 +1,303 @@ +# Permission Model + +Trusted Server runs an Edge Cookie, device, or geo provider only when the +technical permissions that provider requires are set. The permission model is +how a deployer's policy decides whether those permissions are set, without that +policy being baked into the core. + +## Privacy is a spectrum + +Privacy is a spectrum, not a binary, and Trusted Server is technology that is +neutral on policy. Different deployers operate under different laws and run +different policies, so it is the deployer who decides how to configure the +stack. Trusted Server provides the mechanism to establish and check permissions, +and the deployer brings the policy that decides how permissions are established +and what they allow. + +The default deployment makes no host-specific call, creates no identifiers, and +resolves no location until an operator enables a provider. It requires exactly +one policy decision, the default jurisdiction baseline (`[geo] default_country`). +Trusted Server does not assume a jurisdiction for you, so you declare one, and +the examples use the most protective baseline (GDPR-EU). With no Edge Cookie +provider selected there is nothing to gate, so no identifier is created and the +request proceeds. + +## Separating legal policy from the core + +The core does not encode any jurisdiction's law or any single policy. A provider +advertises the technical permissions its data use requires, and the core runs +the provider only when every required permission is set. A provider that +requires nothing always runs, so a vendor-neutral default shows no consent +dialogue and needs no per-request policy interaction. + +## Permission sources + +Permissions are the single currency every service and provider reads. A provider +never reads consent, a consent framework, or any other source directly. It sees +only the resulting permissions, so it cannot depend on how they were derived. + +```mermaid +flowchart LR + G["Country / region"] --> P[["Permissions
(the stable set)"]] + C["Consent signals
(TCF, GPP, GPC)"] --> P + I["Interaction with
the user"] --> P + X["External data
(extension, profile)"] --> P + P --> S["Providers and services
(Edge Cookie, device, geo)"] +``` + +A request's permissions are set by one or more **permission sources**. Consent +is one source among several, not the basis for every permission: + +- **Country and region.** The baseline position for a jurisdiction, from the geo + provider, keyed by ISO 3166-1 with an optional region such as a US state. When + no country is identified, or the country/region has no rule, the deployer's + configured default country applies. A default is required, so there is always + one. +- **Consent signals.** TCF, GPP, or GPC decoded from the request, mapped onto + permissions as a grant or a revoke on top of the baseline. +- **Interaction with the user.** A publisher may establish a preference because + it chooses to, not only because a law requires it. +- **Data from another source.** For example a browser extension, or a person's + profile from an external service. + +The model gates on whether a permission is _set_, not on how it was +established, so any of these sources plugs into the same mechanism. + +### Why this matters + +Implementors of services, features, and providers are protected from the method +used to derive the current request's permissions. They work against a clean, +stable set of permissions that does not change when laws, consent frameworks, or +signal sources change. A new GPP section, a new opt-out signal, or a revised +jurisdiction rule changes a _source_, never the permission a provider checks. + +If a source carries a distinction a consumer needs but no existing permission can +express, the fix is to add a permission to the model, never to leak the source +into the consumer. + +## The permission vocabulary + +The permission names are IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +Europe purposes and used **only** as technical identifiers. No CMP or TCF policy +is implemented in the core. Two purposes have no Data Use yet: purpose 1 (device +storage) uses a proposed `necessary.operations.storage` key, and purpose 11 +keeps its TCF identifier `select-basic-content`. Both are flagged for an upstream +taxonomy addition. All eleven purposes are now resolved against the incoming +consent and privacy signals. A present TCF record grants or revokes each purpose +directly, and where no TCF record is present a US-style opt-out (GPC, a GPP sale +opt-out, or a US Privacy opt-out) is honored. The remaining taxonomy Data Uses +have no TCF purpose, so no signal maps to them and their configured baseline +stands. The mapping itself, which TCF purpose grants which Data Use and what a +US-style opt-out revokes, is declared in the `signals` section of +`permissions.yaml`, not in the code, so a deployer changes policy by editing that +file. + +`permissions.yaml` carries a policy flag for **every** Data Use in the taxonomy, +not only the eleven below. The eleven have a dedicated identifier because a +provider may gate on them. Every other Data Use is listed for completeness and, +where no informed policy decision has been made, is `denied` by default. Trusted +Server is not the policy authority, so a deployer sets the flags to match its own +jurisdiction rules. + +The eleven named Data Uses, with the TCF purpose each maps from: + +| # | Data Use identifier | IAB TCF Europe purpose | +| --- | ----------------------------------------------- | ----------------------------------------------- | +| 1 | `necessary.operations.storage` | Store and/or access information on a device | +| 2 | `advertising_marketing.first_party.contextual` | Use limited data to select advertising | +| 3 | `advertising_marketing.profiling` | Create profiles for personalised advertising | +| 4 | `advertising_marketing.first_party.targeted` | Use profiles to select personalised advertising | +| 5 | `advertising_marketing.personalize.profiling` | Create profiles to personalise content | +| 6 | `advertising_marketing.personalize.content` | Use profiles to select personalised content | +| 7 | `analytics.ad_reporting.measure_ad_performance` | Measure advertising performance | +| 8 | `analytics.ad_reporting.content_performance` | Measure content performance | +| 9 | `analytics.ad_reporting.market_research` | Understand audiences through statistics | +| 10 | `necessary.operations.improve` | Develop and improve services | +| 11 | `select-basic-content` | Use limited data to select content | + +## How providers use permissions + +A provider advertises a required permission set. The core resolves the +permissions it has set for the request, then runs the provider only when every +required permission is set. + +| Provider | Requires | Effect when not set | +| ------------------------- | ------------------------------ | ------------------------- | +| Built-in HMAC Edge Cookie | `necessary.operations.storage` | No Edge Cookie is created | +| A vendor-neutral provider | nothing | Always runs | + +The Edge Cookie `Set-Cookie` operation always requires `necessary.operations.storage` +(Purpose 1), because writing the cookie stores information on the device. + +## Groups and rules + +The policy lives in a human-editable `permissions.yaml` at the repository root, +compiled into the build, so policy owners read and change it in version control. +It has two parts. **Groups** are named baselines, each a set of permission flags. +**Rules** map a country, or a country and state, to a group. The keys are the +codes a geo provider returns, matched case-insensitively. The country is an +ISO 3166-1 alpha-2 code (`FR`), and a state adds the ISO 3166-2 subdivision code +with no country prefix (`US/CA` is California). The Fastly and other geo +providers both emit these codes directly. A region rule takes precedence over its +country. A request that matches no rule, or whose country the geo provider could +not resolve, uses the deployer's configured default country +(`[geo] default_country`). A default is required, so there is always one. + +The country and region rules set only the **baseline** position. They say what +is permitted before any session signal, not what a deployer must ask the user +for. Session signals are then layered on top, and the deployer's own policy +decides how those signals are gathered. + +Each permission flag in a group is one of three acquisition rules, which a +session signal can then change: + +| Flag | Baseline, and how a session signal changes it | +| ----------------- | ------------------------------------------------------------------- | +| `granted` | Set by default, unless a signal revokes it (for example an opt-out) | +| `requires_signal` | Not set by default, set only when a signal grants it | +| `denied` | Never set, even when a signal grants it | + +A group lists every permission and its flag, so its meaning is explicit in the +file. (A group may instead give a single `default` flag for any permission it +omits, but the shipped groups spell every one out.) A rule may then make small +per-permission tweaks on top of its group with a `permissions` map from Data +Use to flag (`granted`, `requires_signal`, or `denied`), each entry overriding +the group baseline for that Data Use. + +### Why three states, not two + +`requires_signal` and `denied` both start unset, so they can look like the same +"off" state, but they answer different questions and a session signal treats +them differently. + +- `requires_signal` means the Data Use is permitted **with** a signal. A grant, + for example TCF consent to the mapped purpose, sets it. +- `denied` means the Data Use is not permitted here at all. A grant **cannot** + set it. This models a jurisdiction where there is no lawful basis for the use, + so a consent signal is irrelevant. + +For a worked example, take a deployer who sets +`advertising_marketing.profiling: denied` for a country that does not permit +profiling. A request arrives with a TCF string that consents to Purpose 3 +(create profiles for personalised advertising), which maps to that Data Use. The +resolver pairs the `denied` baseline with the grant signal and still leaves the +permission **unset**, so a provider that requires profiling does not run. The +same consent against a `requires_signal` baseline would set it. Consent lifts +`requires_signal`; it never lifts `denied`. + +The shipped `permissions.yaml` defines `gdpr-eu`, `gdpr-uk`, and `us-opt-out` +groups, and maps the EU 27 and the EEA members (IS, LI, NO) to `gdpr-eu`, the +UK to `gdpr-uk`, and the US (with all 50 states and DC) and Australia to +`us-opt-out`. For device storage (Purpose 1), that yields: + +| Country | Device storage (Purpose 1) | +| -------------- | --------------------------------------------------------------- | +| EU 27 and EEA | Requires signal (opt-in) | +| United Kingdom | Granted (no signal required under the reformed ePrivacy regime) | +| United States | Granted (opt-out) | +| Australia | Granted | + +These are defaults to modify or replace, not legal advice. The deployer must set +the default country for unmatched requests in `trusted-server.toml` +(`[geo] default_country`). It is required and validated at startup against these +rules, so startup fails when it is unset or names no rule. A rule that names a +group not defined in the file, or a flag that is not `granted`, +`requires_signal`, or `denied`, is rejected at build time, so a typo is caught +rather than silently ignored. + +## How a request resolves + +A permission is _set_ when Trusted Server may rely on it for this request, and +unset otherwise. A provider runs only when every permission it requires is +set. + +Signal precedence is fixed in code, most restrictive first. A US-style opt-out +(GPC, a GPP sale opt-out, or a US Privacy opt-out) suppresses the Data Uses +the policy revokes even when a TCF record consents, because an explicit +opt-out is never overridden by another signal. A consent record that is +present but cannot be decoded blocks baseline grants (fail-closed) rather +than degrading to the no-signal baseline. Only then does a TCF record decide +the Data Uses its purposes map to. Opt-outs suppress use for the request; +they never destroy an already-issued identifier. Destructive withdrawal (the +cookie expired and the identity-graph row tombstoned) happens only when a TCF +record refuses storage in a jurisdiction whose baseline did not grant it. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Lookup{Geo lookup
succeeded?} + Lookup -- "Failed" --> Floor[Requires-signal floor] + Lookup -- "Yes" --> Rules{Region or country
has a rule?} + Rules -- "Yes" --> CountryMap[Use that baseline] + Rules -- "No or none resolved" --> Default[Use the configured default country] + Floor --> PerPerm + CountryMap --> PerPerm + Default --> PerPerm + + PerPerm[For each permission] --> Rule{Baseline rule?} + Rule -- "Granted" --> Revoke{Signal
revokes?} + Revoke -- "No" --> Set[Permission set] + Revoke -- "Yes" --> Unset[Permission unset] + Rule -- "Requires signal" --> Grant{Signal
grants?} + Grant -- "Yes" --> Set + Grant -- "No" --> Unset + Rule -- "Denied" --> Unset + + Set --> Check{Provider's required
permissions all set?} + Unset --> Check + Check -- "Yes" --> Run([Run provider]) + Check -- "No" --> Skip([Skip provider]) +``` + +## Configuration + +The geo provider, which resolves the country, and the default country for +unmatched requests are selected in `trusted-server.toml`. The country and region +rules live in the human-editable `permissions.yaml` at the repository root, +compiled into the build (not loaded at runtime). + +```toml +# trusted-server.toml selects the geo provider and the default country used when +# a request matches no rule (or the geo provider resolves no country). +[geo] +provider = "platform" +default_country = "US" +# With no geo provider, every request is treated as default_country. A +# deployment that runs an Edge Cookie provider without a geo provider must +# acknowledge that explicitly: +# assume_single_jurisdiction = true +``` + +The default country covers requests the geo provider leaves unmatched. A +failed geo lookup is different: it resolves every permission to the +requires-signal floor instead of the default, so an outage is handled +protectively rather than as the deployer's default jurisdiction, and it is +logged at error level. + +```yaml +# permissions.yaml (excerpt). Each group lists every permission and its flag. +# Rules map countries and states to a group. +groups: + gdpr-eu: # opt-in, every purpose requires a signal + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + # ... the remaining purposes, also requires_signal + us-opt-out: # opt-out, every purpose granted + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + # ... the remaining purposes, also granted + +rules: + FR: gdpr-eu + US: us-opt-out + US/CA: # a state can override single flags on top of its group + group: us-opt-out + permissions: + advertising_marketing.first_party.targeted: denied +``` + +## Relationship to Edge Cookies + +Edge Cookie creation is gated through this model: the built-in HMAC provider +requires `necessary.operations.storage`, so an Edge Cookie is created only when that +permission is set. See [Edge Cookies](/guide/edge-cookies) for the full +request lifecycle. diff --git a/permissions.yaml b/permissions.yaml new file mode 100644 index 0000000000..39eeba13d7 --- /dev/null +++ b/permissions.yaml @@ -0,0 +1,345 @@ +# Country and region permission rules for Trusted Server's permission model. +# +# This file is compiled into the build, not loaded at runtime. Editing it and +# rebuilding is how a deployer changes the default policy, so the rules stay +# visible and reviewable in version control. +# +# There are three parts: +# groups named permission baselines, defined once and referenced by rules +# rules map a country (FR) or country/state (US/CA) to a group, with optional +# explicit per-permission overrides +# signals how each session signal (TCF, US-style opt-out) maps onto Data Uses, +# so no signal-to-permission policy lives in the code +# +# Rule keys use the codes a geo provider returns, matched case-insensitively: +# country ISO 3166-1 alpha-2 (for example FR, US, GB) +# country/state adds the ISO 3166-2 subdivision code, with no country prefix +# (for example US/CA for California) +# The Fastly and other geo providers both emit these codes directly. +# +# A request whose country/region matches no rule, or whose country the geo +# provider cannot resolve, uses the deployer's default set in trusted-server.toml +# as [geo] default_country. A default is required, so there is always a default +# permission set; startup fails when none is configured. +# +# A permission flag is one of: +# granted set without any signal (for example strictly necessary) +# requires_signal set only when a signal grants it (opt-in) +# denied never set, even when a signal grants it +# +# Permission names are the IAB Privacy Taxonomy Data Uses, mapped from the IAB +# TCF Europe purposes. Every group below lists every Data Use so its policy is +# fully explicit. Two purposes have no Data Use yet: TCF purpose 1 (device +# storage) uses a proposed `necessary.operations.storage` key, and TCF purpose +# 11 (limited-data content selection) keeps its TCF identifier +# `select-basic-content`. Both are flagged for an upstream taxonomy addition. +# +# Trusted Server is not the policy authority. Where no informed policy decision +# has been made for a Data Use it is `denied`, so a demo or test deployment has +# a complete, conservative default. A deployer edits this file to set its own +# policy per jurisdiction. +# +# Named baselines. Each group lists every Data Use and its flag, so a group's +# meaning is explicit. (A `default: ` shorthand is also accepted for any +# Data Use a group omits.) +groups: + # European Union: opt-in; the modeled ad-tech purposes require a signal. + gdpr-eu: + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + + # United Kingdom: device storage proceeds without a signal under the reformed ePrivacy regime. + gdpr-uk: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + + # United States: opt-out; the modeled ad-tech purposes granted by default. + us-opt-out: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + advertising_marketing.profiling: granted + advertising_marketing.first_party.targeted: granted + advertising_marketing.personalize.profiling: granted + advertising_marketing.personalize.content: granted + analytics.ad_reporting.measure_ad_performance: granted + analytics.ad_reporting.content_performance: granted + analytics.ad_reporting.market_research: granted + necessary.operations.improve: granted + select-basic-content: granted + # Remaining Privacy Taxonomy Data Uses: no policy decision made yet, + # so denied by default (see the file header). + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.communications.email: denied + advertising_marketing.communications.sms: denied + advertising_marketing.first_party: denied + advertising_marketing.frequency_capping: denied + advertising_marketing.negative_targeting: denied + advertising_marketing.personalize: denied + advertising_marketing.personalize.system: denied + advertising_marketing.serving: denied + advertising_marketing.third_party: denied + advertising_marketing.third_party.targeted: denied + analytics: denied + analytics.ad_reporting: denied + analytics.ad_reporting.ad_delivery_and_targeting: denied + analytics.ad_reporting.ad_fraud_detection: denied + analytics.ad_reporting.ad_viewability: denied + analytics.ad_reporting.campaign_insights: denied + analytics.reporting: denied + analytics.reporting.system: denied + disclosure: denied + disclosure.law_enforcement: denied + disclosure.outside_counsel: denied + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + functional: denied + functional.performance: denied + functional.personalization: denied + functional.security: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.employment.hr.hiring: denied + necessary.fraud_detection: denied + necessary.legal_obligation: denied + necessary.legal_obligation.age_verification: denied + necessary.legal_obligation.content_moderation: denied + necessary.legal_obligation.dsr: denied + necessary.legal_obligation.hold: denied + necessary.operations: denied + necessary.operations.authentication: denied + necessary.operations.debugging: denied + necessary.operations.notifications: denied + necessary.operations.notifications.email: denied + necessary.operations.notifications.sms: denied + necessary.operations.payment_processing: denied + necessary.operations.quality_assurance: denied + necessary.operations.security: denied + necessary.operations.support: denied + necessary.operations.survey: denied + necessary.operations.upgrades: denied + necessary.operations.website_use: denied + +# Map a country, or a country and state (country/state), to a group. Add explicit +# per-permission overrides with +permission (granted) or -permission (denied). +# These win over the group baseline. +rules: + # European Union (27). + AT: gdpr-eu + BE: gdpr-eu + BG: gdpr-eu + HR: gdpr-eu + CY: gdpr-eu + CZ: gdpr-eu + DK: gdpr-eu + EE: gdpr-eu + FI: gdpr-eu + FR: gdpr-eu + DE: gdpr-eu + GR: gdpr-eu + HU: gdpr-eu + IE: gdpr-eu + IT: gdpr-eu + LV: gdpr-eu + LT: gdpr-eu + LU: gdpr-eu + MT: gdpr-eu + NL: gdpr-eu + PL: gdpr-eu + PT: gdpr-eu + RO: gdpr-eu + SK: gdpr-eu + SI: gdpr-eu + ES: gdpr-eu + SE: gdpr-eu + + # European Economic Area (non-EU): GDPR applies through the EEA agreement. + IS: gdpr-eu + LI: gdpr-eu + NO: gdpr-eu + + GB: gdpr-uk + AU: us-opt-out + + # United States. Every state follows the country rule until a state entry + # overrides it: a country/state key takes precedence over the country, so add + # a row only where a state's law differs from the baseline, for example: + # US/CA: + # group: us-opt-out + # permissions: + # advertising_marketing.first_party.targeted: denied + # Each permissions entry maps a Data Use to granted, requires_signal, or + # denied, overriding the group's baseline for that Data Use. Or reference a + # stricter group. + US: us-opt-out + +# How each session signal maps onto Data Uses. The permission engine reads this, +# so no signal-to-permission policy lives in the code. For each Data Use a signal +# produces a grant or a revoke, which the resolver then applies against the group +# baseline above (a grant sets a `requires_signal` Data Use, a revoke drops a +# `granted` one, and `denied` always wins so no signal can set it). +signals: + # A present TCF v2 record (a standalone TC string, or the EU TCF section of a + # GPP string). With authoritative true, each listed TCF purpose grants the + # Data Use it maps to when the record consents to that purpose, and revokes it + # otherwise; with authoritative false the record is ignored. The flag governs + # only the TCF record's own grants and revokes: an opt-out signal below always + # suppresses the Data Uses it revokes, even alongside a consenting TCF record, + # because an explicit opt-out is never overridden by another signal. This is + # the interim home for the purpose to Data Use mapping. Once the IAB Privacy + # Taxonomy tcf column is finalized (fideslang) that becomes the single source + # and this block is dropped. + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 2: advertising_marketing.first_party.contextual + 3: advertising_marketing.profiling + 4: advertising_marketing.first_party.targeted + 5: advertising_marketing.personalize.profiling + 6: advertising_marketing.personalize.content + 7: analytics.ad_reporting.measure_ad_performance + 8: analytics.ad_reporting.content_performance + 9: analytics.ad_reporting.market_research + 10: necessary.operations.improve + 11: select-basic-content + # US-style opt-out of sale or sharing. It applies when any listed source is + # set, and it suppresses the Data Uses it revokes even when a TCF record + # consents to them. `revokes: all` drops every granted Data Use, and the map + # bounds what that actually touches. A deployer can instead list specific + # Data Uses, for example the sharing and targeted advertising uses. An + # opt-out suppresses use for the request; it is never a destructive + # withdrawal of an already-issued identifier. + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: all diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index ad9939d0a6..ae81041690 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -192,6 +192,11 @@ s = s.replace('proxy_secret = "change-me-proxy-secret"', s = re.sub(r'passphrase = "[^"]*"', 'passphrase = "local-harness-ec-passphrase-not-a-real-one"', s, count=1) +# The example ships the most protective default jurisdiction (FR, where the +# server-side auction needs TCF consent). The harness exercises the auction with +# no consent signals, so it runs as a US opt-out state. +s = s.replace('default_country = "FR"', 'default_country = "US/CA"', 1) + # A real auction, pointed at the stub's slow endpoint, so the timings mean something. s = s.replace('[integrations.prebid]\nenabled = false\nserver_url = "https://prebid.example.com/openrtb2/auction"', f'[integrations.prebid]\nenabled = true\nserver_url = "http://127.0.0.1:{port}/bid"\n' diff --git a/trusted-server.example.toml b/trusted-server.example.toml index d73c436d0f..8dabf694fb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -129,11 +129,24 @@ pull_sync_concurrency = 3 # [device] # provider = "builtin" -# Geo / IP intelligence provider selection. The platform host geo is the -# default. Set provider = "none" to resolve no location and make no host geo -# call. -# [geo] +[geo] +# Geo / IP intelligence provider selection. No provider is the default: +# Trusted Server resolves no location and makes no host geo call. The host +# platform's own lookup is opt-in. # provider = "platform" +# +# The permission baseline for a request the geo provider leaves unmatched +# (and, with no geo provider, for every request). Required: startup fails +# without it. A country ("US") or country/region ("US/CA") with a rule in +# permissions.yaml. +default_country = "US" +# +# With no geo provider, every request is treated as default_country, so a +# visitor from another jurisdiction receives the default jurisdiction's +# permission rules. A deployment that runs an Edge Cookie provider without a +# geo provider must acknowledge that by uncommenting the line below, or +# select a geo provider instead. +# assume_single_jurisdiction = true # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] From 68f67d06edbfc9d28cd4acf5b72d184064328339 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:08:58 +0100 Subject: [PATCH 02/82] Correct the permission-model docs to what the code does The permissions module doc said device and geo providers are refused when their required permissions are not set; only the Edge Cookie provider is gated today, and the built-in device and geo providers require none. It also said only two purposes are resolved against signals, while every TCF purpose is. An intra-doc link pointed at a function that does not exist. The spec and guide now say that publisher navigation and page-bids user.id ride the sharing pair, that a malformed permissions.yaml fails at settings load, that there are three opt-out-over-TCF pinning tests, that a US-style opt-out revokes whether or not a TCF record is present, and that the US has one country rule. --- crates/trusted-server-core/src/ec/mod.rs | 2 +- crates/trusted-server-core/src/permissions.rs | 20 ++++++++++--------- docs/guide/permission-model.md | 13 ++++++------ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 90d598e0c1..6af4f0c9e9 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -749,7 +749,7 @@ impl EcContext { /// permissions are set for this request. /// /// Resolved once at construction through the permission model (see - /// [`consent::ec_permission_granted`]). + /// [`consent::assemble_permissions`]). #[must_use] pub fn ec_allowed(&self) -> bool { self.ec_allowed diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 5e31c31b61..5831ea5a98 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -1,19 +1,21 @@ //! 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. +//! A provider 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 run the Edge Cookie +//! provider when its required permissions are not set. The device and geo +//! providers declare their requirements through the same method, and the +//! built-in ones require none; gating their execution on that declaration is +//! follow-up work. //! //! 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. +//! identifier. No CMP or TCF *policy* is implemented here. Every TCF purpose +//! is resolved against the session's signals (a present TCF record grants or +//! revokes each purpose, and a US-style opt-out revokes), and the Data Uses +//! with no TCF purpose keep their configured baseline. //! //! 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`] diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 0e89e5b593..d4b1c7413c 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -1,7 +1,8 @@ # 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 +Trusted Server runs the Edge Cookie provider only when the technical +permissions it requires are set, and the device and geo providers declare +their requirements the same way. The permission model is how a deployer's policy decides whether those permissions are set, without that policy being baked into the core. @@ -84,8 +85,8 @@ 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 +directly, and a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy +opt-out) revokes whether or not a TCF record is present. The remaining taxonomy Data Uses 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 @@ -187,8 +188,8 @@ same consent against a `requires_signal` baseline would set it. Consent lifts The shipped `permissions.yaml` defines `gdpr-eu`, `gdpr-uk`, and `us-opt-out` groups, and maps the EU 27 and the EEA members (IS, LI, NO) to `gdpr-eu`, the -UK to `gdpr-uk`, and the US (with all 50 states and DC) and Australia to -`us-opt-out`. For device storage (Purpose 1), that yields: +UK to `gdpr-uk`, and the US (one country-level rule, with state rules +available as overrides) and Australia to `us-opt-out`. For device storage (Purpose 1), that yields: | Country | Device storage (Purpose 1) | | -------------- | --------------------------------------------------------------- | From 4c889b01f868bdef2e39e9200984ca8b794036a2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 04:40:58 +0100 Subject: [PATCH 03/82] Give the remaining test fixtures the now-required default country This branch makes `[geo] default_country` required whenever an Edge Cookie provider is configured, and the shared test config in crates/trusted-server-core/src/test_support.rs gained it. Five inline fixtures that build Settings directly did not, so they failed validation at `Settings::from_toml` and panicked: - crates/trusted-server-adapter-cloudflare/src/app.rs and crates/trusted-server-adapter-spin/src/app.rs (`UNINJECTED_PROVIDER_TOML`, used by `build_ec_context_fails_when_the_selected_provider_is_unavailable`) - crates/trusted-server-adapter-cloudflare/tests/routes.rs and crates/trusted-server-adapter-spin/tests/routes.rs (`selecting_a_provider_this_adapter_cannot_supply_fails_at_startup`) - crates/trusted-server-adapter-fastly/src/app.rs, in `dispatch_edge_authenticated_esi_request_stores_then_hits_template`, which configures the deprecated `[ec] passphrase`; that migrates to the hmac provider, so it needs the default country too Each fixture gets `default_country = "FR"` and `assume_single_jurisdiction = true`, the same pair and reasoning as the shared test config: FR is the gdpr-eu baseline where every permission requires a signal, and no geo provider is selected in these tests. Verified with `cargo test-cloudflare`, `cargo test-spin`, `cargo test-fastly` and `cargo test-axum`, all of which failed before this change. Produced with AI assistance; needs human review. --- crates/trusted-server-adapter-cloudflare/src/app.rs | 7 +++++++ crates/trusted-server-adapter-cloudflare/tests/routes.rs | 7 +++++++ crates/trusted-server-adapter-fastly/src/app.rs | 8 ++++++++ crates/trusted-server-adapter-spin/src/app.rs | 7 +++++++ crates/trusted-server-adapter-spin/tests/routes.rs | 7 +++++++ 5 files changed, 36 insertions(+) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index fecec85a22..459cc91444 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -691,6 +691,13 @@ mod tests { [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because + # no geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// The per-request Edge Cookie read must return its error rather than a diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 992fca4dc2..e869a29382 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -714,6 +714,13 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because + # no geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// A provider selection this adapter can never supply must fail while the diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e9a6f93e97..90c220248e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2912,6 +2912,14 @@ mod tests { [ec] passphrase = "test-secret-key-32-bytes-minimum" + # The deprecated passphrase migrates to the hmac provider, so + # the permission model needs a default country, and + # single-jurisdiction operation acknowledged because no geo + # provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true + [auction] enabled = true providers = [] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 97b2f8bc53..feedfbbc29 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -975,6 +975,13 @@ mod tests { [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because + # no geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// The per-request Edge Cookie read must return its error rather than a diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 3a0757cd7f..b6e9d0ae0a 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -1005,6 +1005,13 @@ const UNINJECTED_PROVIDER_TOML: &str = r#" [ec.providers.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so the permission model needs a + # default country, and single-jurisdiction operation acknowledged because + # no geo provider is selected. + [geo] + default_country = "FR" + assume_single_jurisdiction = true "#; /// A provider selection this adapter can never supply must fail while the From bb56ceb63051d22c51f043d7d6e69503bf71487a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 13:39:11 +0100 Subject: [PATCH 04/82] Say which geo lookups can reach the requires-signal floor, and prove it The permission model spec promised that a failed geo lookup resolves every permission to the requires-signal floor and never the deployer's `[geo] default_country`, calling it the fail-closed handling of an outage. The rule is implemented correctly, but nothing could reach it. Every `PlatformGeo` implementation in the workspace returns `Ok` on every path, and `PlatformError::Geo` has no construction site at all, so the only production route to `GeoStatus::Failed` (the `Err` arm in `read_from_request_resolving_geo`) is dead. The only test of the state built the enum by hand. Checking whether a host lookup can fail settles which half to change. Fastly's `geo_lookup` is declared `Option`, and the SDK collapses every hostcall status, buffer-size and deserialization failure into `None` before the caller sees it. The Cloudflare provider reads request headers, where every parse failure degrades to empty. Axum and Spin have no host geo service. `DisabledGeo`, the default unless `[geo] provider` is `"platform"`, resolves nothing by construction. So no adapter shipped today can surface a failure, and a real geo outage arrives as `Ok(None)`, which is `NoLocation`, and falls through to the default country. With a default that grants storage, that is fail-open where the spec claimed fail-closed. Rather than invent a failure the hosts cannot have, I left the mechanism alone and made the docs say what actually happens. The `Result` on `PlatformGeo::lookup` is the contract for a provider that does its own fallible lookup, which is exactly what a vendor crate under `crates/geo/` would be, so the floor is a live rule for a provider that can fail and an unreachable one for the providers shipped now. Both the spec and the operator guide now say so plainly, and both tell a deployer the thing they will act on: whatever the default country grants is what a geo outage grants, so choose it on that basis. The new test drives the failure through the seam rather than constructing the status, using a `PlatformGeo` that returns `PlatformError::Geo` and a default country that grants storage, so it can only pass if the failure reaches the floor. `build_services_with_geo` is the injection point it needed. --- crates/trusted-server-core/src/ec/mod.rs | 48 +++++++++++++++++++ .../src/platform/test_support.rs | 18 +++++++ docs/guide/permission-model.md | 18 +++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 6af4f0c9e9..bd2b049968 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1735,6 +1735,54 @@ mod tests { ); } + /// A geo provider whose lookup fails, the state the permission model's + /// fail-closed rule exists for. + /// + /// No geo provider shipped in this workspace can fail: the Fastly SDK's + /// `geo_lookup` returns an `Option`, the Cloudflare provider reads request + /// headers, and the Axum and Spin providers resolve nothing at all. The + /// `Result` on [`PlatformGeo::lookup`] is there for a provider that does + /// its own fallible lookup, so this stands in for one and proves the floor + /// is reached through the seam rather than only from a hand-built status. + #[derive(Debug)] + struct FailingGeo; + + impl crate::platform::PlatformGeo for FailingGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + Err(Report::new(crate::platform::PlatformError::Geo)) + } + } + + #[test] + fn a_geo_provider_failure_resolves_permissions_at_the_requires_signal_floor() { + use crate::permissions::Permission; + use crate::platform::test_support::build_services_with_geo; + + // A default country that grants storage, so the assertion can only + // pass by the failure reaching the floor rather than the default. + let mut settings = create_test_settings(); + settings.geo.default_country = Some("US/CA".to_owned()); + let req = create_test_request(&[]); + + let granted = EcContext::read_from_request_resolving_geo(&settings, &req, &noop_services()) + .expect("should read EC context with a geo provider that resolves nothing"); + assert!( + granted.permissions().is_set(Permission::StoreOnDevice), + "the default country must grant storage, or this test proves nothing" + ); + + let services = build_services_with_geo(std::sync::Arc::new(FailingGeo)); + let failed = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .expect("a failed lookup should resolve permissions, not fail the request"); + assert!( + !failed.permissions().is_set(Permission::StoreOnDevice), + "a geo provider failure must resolve at the requires-signal floor, not the default" + ); + } + #[test] fn no_location_resolves_the_consent_jurisdiction_from_the_default_country() { use crate::consent::jurisdiction::Jurisdiction; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 7f9b850fc0..b4f68e1c39 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -688,6 +688,24 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected geo provider, so a test can +/// drive a geo outcome through the [`PlatformGeo`] seam rather than +/// constructing the resolved status by hand. +/// +/// This is the only way to reach the lookup-failure path, because the seam is +/// what turns an `Err` into the requires-signal floor. +pub(crate) fn build_services_with_geo(geo: 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(geo) + .client_info(ClientInfo::default()) + .build() +} + /// Build a [`RuntimeServices`] carrying an Edge Cookie provider, so a test can /// exercise the seam an opaque-identifier vendor provider reaches core through. pub(crate) fn noop_services_with_ec_provider( diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index d4b1c7413c..2ac2a56674 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -249,6 +249,10 @@ flowchart TD Check -- "No" --> Skip([Skip provider]) ``` +The "Failed" branch is the rule for a geo provider that can report a failed +lookup. None of the providers shipped today can, so a geo outage takes the +"No or none resolved" branch instead. See the default-country note below. + ## Configuration The geo provider, which resolves the country, and the default country for @@ -270,9 +274,17 @@ default_country = "US" The default country covers requests the geo provider leaves unmatched. A failed geo lookup is different: it resolves every permission to the -requires-signal floor instead of the default, so an outage is handled -protectively rather than as the deployer's default jurisdiction, and it is -logged at error level. +requires-signal floor instead of the default, and is logged at error level. + +Read that alongside which lookups can actually fail, because it decides how +much the default country is doing for you. None of the geo providers shipped +today can fail. Fastly's lookup returns "no data" rather than an error, the +Cloudflare provider reads request headers, and the Axum and Spin providers +resolve nothing at all. **A geo outage therefore reaches the default country, +not the floor**, because "no data" and "the deployer left this unmatched" are +the same state. Choose the default country on that basis: whatever it grants +is what an outage grants. The floor applies to a geo provider that does its +own lookup and can report a failure. ```yaml # permissions.yaml (excerpt). Each group lists every permission and its flag. From 8adbb0667d93fdc2de84348c00df21e188afbcc1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 12:24:22 +0100 Subject: [PATCH 05/82] Scope the permission-gating claim to the provider it is true of The operator guide said the core runs a provider only when every permission that provider requires is set, written generically enough to cover all three provider kinds. Only the Edge Cookie provider is actually gated that way. The single production read of required_permissions is in ec/mod.rs. Device and geo providers declare a permission set through the same method and nothing checks it, so the reads in ec/device.rs and platform/mod.rs are both inside test modules. Three sentences said the general thing. Each is now scoped to the Edge Cookie provider, and the guide states plainly that a device or geo declaration is recorded rather than enforced, so an operator does not rely on it to keep a provider from running. Addresses: docs/guide/permission-model.md, claim without code behind it --- docs/guide/permission-model.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 2ac2a56674..88a254896e 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -27,9 +27,18 @@ request proceeds. 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. +the Edge Cookie provider only when every permission that provider requires is +set. An Edge Cookie provider that requires nothing always runs, so a +vendor-neutral default shows no consent dialogue and needs no per-request +policy interaction. + +Device and geo providers declare their required permissions through the same +method, and the core does not yet gate either of them on what they declare, so +for those two the declaration is recorded rather than enforced. The only place +a declaration currently decides whether a provider runs is the Edge Cookie +path, in `ec/mod.rs`. Treat a device or geo declaration as a statement of +intent until that gap is closed, and do not rely on it to keep a provider from +running. ## Permission sources @@ -119,8 +128,11 @@ The eleven named Data Uses, with the TCF purpose each maps from: ## 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. +permissions it has set for the request, then runs the Edge Cookie provider only +when every permission that provider requires is set. Device and geo providers +advertise a set in the same way, and nothing gates them on it yet, so the table +below lists only the provider whose declaration currently decides whether it +runs. | Provider | Requires | Effect when not set | | ------------------------- | ------------------------------ | ------------------------- | @@ -209,8 +221,9 @@ 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. +unset otherwise. The Edge Cookie provider runs only when every permission it +requires is set, which is the one place a declaration currently decides whether +a provider runs. Signal precedence is fixed in code, most restrictive first. A US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) suppresses the Data Uses From f446393b2cffcaab95dbbd4328f3cb9615ca1880 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 13:16:01 +0100 Subject: [PATCH 06/82] State that evidence is not rationed and use is The permission model is easy to read as though the core decides which vendor may see which signal. It does not, and writing code on that assumption works against the architecture. Every provider and every integration sees all the evidence available for a request, host signals included. The core stays neutral between vendors, so it never withholds a signal from one and not another. What a vendor may do with what it sees is the governed part, decided by the permissions it declares and the permission model sets. Access is universal, use is gated. Recorded in CLAUDE.md, which every contributor and coding agent reads, and in the operator guide, which states the same thing for someone configuring a deployment. Both say the practical consequence plainly: if a use needs controlling, express it as a permission rather than by hiding the signal. Addresses: the permission model's neutrality between vendors, previously implied but never stated --- CLAUDE.md | 13 +++++++++++++ docs/guide/permission-model.md | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 53b50a6d28..06f2d595b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,6 +312,19 @@ configuration can set a permission with no consent at all. "runs without any consent". - A gated provider **runs once its required permissions are set**, by whatever method. +**Evidence is not rationed, use is.** Every provider and every integration sees +all the evidence available for a request, including host signals such as the TLS +JA4 and HTTP/2 fingerprints. The core never decides which vendor may see what, +because withholding a signal from one vendor and not another discriminates +between them, and the core stays neutral. What a vendor may *do* with the +evidence is governed by the permissions it declares and the system sets. Access +is universal, use is gated. + +The practical consequence: never "fix" a vendor's access to a signal by hiding +the signal. If a use needs controlling, express it as a permission. A change +that removes evidence from a provider's reach is working against the +architecture, not protecting it. + - 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 diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 88a254896e..2b0274de48 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -40,6 +40,21 @@ path, in `ec/mod.rs`. Treat a device or geo declaration as a statement of intent until that gap is closed, and do not rely on it to keep a provider from running. +## Evidence is not rationed, use is + +Every provider sees all the evidence available for a request. Trusted Server +does not decide which vendor is allowed to see which signal, because +withholding a signal from one vendor and not another would discriminate between +them, and the core stays neutral between vendors. + +What a vendor may do with what it sees is the part that is governed. A provider +declares the permissions its data use requires, and the permission model decides +whether each one is set. So access is universal and use is gated, rather than +the other way round. + +If you are looking for a way to stop a vendor using a signal, the answer is a +permission, not a hidden signal. + ## Permission sources Permissions are the single currency every service and provider reads. A provider From 292352ac9b2a35e9bda3c271303faa7f2a11548d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 22:47:09 +0100 Subject: [PATCH 07/82] Name the IAB Tech Lab Privacy Taxonomy as the source of the vocabulary The permission model reads as though the permissions were invented here. They were not. They are the IAB Tech Lab Privacy Taxonomy Data Uses, with the IAB TCF Europe purposes mapped onto them where no Data Use exists yet, and that was recorded only in doc comments inside the code. Someone reading permissions.yaml, which is the file an operator edits to change policy, had no way of knowing where the names came from. It says so now, and the operator guide carries a short section on the same point, because a declaration an auditor can check against a published taxonomy is worth considerably more than one they can only check against us. Three clause-joining colons in the surrounding guide prose are fixed in the same pass. Addresses: permissions.yaml and docs/guide/permission-model.md, an unattributed vocabulary --- docs/guide/permission-model.md | 21 +++++++++++++++++---- permissions.yaml | 10 +++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 2b0274de48..37c1ff7a08 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -23,6 +23,19 @@ 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. +## Where the vocabulary comes from + +The permissions are not a vocabulary this project invented. They are the IAB +Tech Lab Privacy Taxonomy Data Uses, with the IAB TCF Europe purposes mapped +onto them where no Data Use exists yet. + +That matters for reading the rest of this guide. When a provider declares the +permissions its data use requires, it is naming a Data Use from that taxonomy, +so an operator or an auditor can check the declaration against the taxonomy +rather than against our interpretation of it. The mapping from TCF purposes is +recorded in `permissions.yaml` alongside the rules, so no signal-to-permission +policy is hidden in code. + ## Separating legal policy from the core The core does not encode any jurisdiction's law or any single policy. A provider @@ -104,7 +117,7 @@ into the consumer. 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 +is implemented in the core. Two purposes have no Data Use yet, being 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 @@ -301,7 +314,7 @@ default_country = "US" ``` The default country covers requests the geo provider leaves unmatched. A -failed geo lookup is different: it resolves every permission to the +failed geo lookup is different, because it resolves every permission to the requires-signal floor instead of the default, and is logged at error level. Read that alongside which lookups can actually fail, because it decides how @@ -310,7 +323,7 @@ today can fail. Fastly's lookup returns "no data" rather than an error, the Cloudflare provider reads request headers, and the Axum and Spin providers resolve nothing at all. **A geo outage therefore reaches the default country, not the floor**, because "no data" and "the deployer left this unmatched" are -the same state. Choose the default country on that basis: whatever it grants +the same state. Choose the default country on that basis, meaning whatever it grants is what an outage grants. The floor applies to a geo provider that does its own lookup and can report a failure. @@ -338,7 +351,7 @@ rules: ## Relationship to Edge Cookies -Edge Cookie creation is gated through this model: the built-in HMAC provider +Edge Cookie creation is gated through this model, because 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 index 39eeba13d7..1e9e17fa0f 100644 --- a/permissions.yaml +++ b/permissions.yaml @@ -1,5 +1,12 @@ # Country and region permission rules for Trusted Server's permission model. # +# The vocabulary is not ours. The permissions are the IAB Tech Lab Privacy +# Taxonomy Data Uses, with the IAB TCF Europe purposes mapped onto them where +# no Data Use exists yet. That is deliberate, because a permission a provider +# declares should be stated in the industry's own terms rather than in a +# vocabulary this project invented, so a reader can check it against the +# taxonomy rather than against us. +# # 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. @@ -43,7 +50,8 @@ # 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. + # European Union, where the model is opt-in, so the modeled ad-tech purposes + # require a signal. gdpr-eu: necessary.operations.storage: requires_signal advertising_marketing.first_party.contextual: requires_signal From 625e816d4044c8a07d20fcd5505b3840635f269d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 23:06:36 +0100 Subject: [PATCH 08/82] Build the test provider code through the macro that cannot fail ProviderCode::new returns an Option since it stopped panicking on a malformed code, and one test provider on this branch still used it as though it returned the code directly, so the test target did not compile here even though it compiles further up the chain where a later commit had already moved it. That is worth noting beyond the one line. cargo check does not build test code, so a per-branch check passes while the test target is broken, and the fault only shows at whichever branch someone happens to run the tests on. Each pull request in this chain has to stand on its own, so it is fixed where it breaks. Addresses: crates/trusted-server-core/src/ec/provider.rs:1752, test target not compiling on this branch --- crates/trusted-server-core/src/ec/provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index f58fc1642c..b1bae94a5c 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -1776,7 +1776,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0mi") + crate::provider_code!("t0mi") } fn generate( From 0dd10fd681793da7d500936af53b65210fce28c0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 10:53:49 +0100 Subject: [PATCH 09/82] Documentation and house-style cleanup for the permission model PR --- CLAUDE.md | 13 +++--- crates/trusted-server-core/src/permissions.rs | 44 +++++++++++-------- docs/guide/permission-model.md | 15 ++++--- permissions.yaml | 19 +++++--- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 06f2d595b0..78da96d53b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ crates/ 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) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) geo/ # vendor geo provider crates (host geo is injected by the adapter) trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline @@ -304,7 +304,7 @@ impl core::error::Error for MyError {} 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 +is only one of many 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. @@ -312,9 +312,10 @@ configuration can set a permission with no consent at all. "runs without any consent". - A gated provider **runs once its required permissions are set**, by whatever method. + **Evidence is not rationed, use is.** Every provider and every integration sees all the evidence available for a request, including host signals such as the TLS -JA4 and HTTP/2 fingerprints. The core never decides which vendor may see what, +JA4 and HTTP/2 signals. The core never decides which vendor may see what, because withholding a signal from one vendor and not another discriminates between them, and the core stays neutral. What a vendor may *do* with the evidence is governed by the permissions it declares and the system sets. Access @@ -366,15 +367,15 @@ Principles for adding or changing a provider: 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 + host signals such as the TLS JA4 and HTTP/2 signals) 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. + host signal 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, + multiple 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. diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 5831ea5a98..e75ebc1f36 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -25,7 +25,9 @@ //! 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. +//! grant it. A geo provider that reports an outright lookup failure is the +//! exception, resolving every permission to the requires-signal floor rather +//! than the default, though no geo provider shipped today reports one. use std::collections::BTreeMap; use std::sync::OnceLock; @@ -35,14 +37,15 @@ 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. +/// Only the identifier is used, with no TCF or taxonomy policy implemented. Every +/// named variant with a TCF purpose in `permissions.yaml` is resolved against +/// the session's signals. Only [`Permission::StoreOnDevice`] (and +/// [`Permission::SelectPersonalisedAds`] for sharing) gates a shipped provider +/// 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 + /// TCF Purpose 1, store and/or access information on a device. It gates the + /// built-in Edge Cookie provider 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, @@ -260,7 +263,7 @@ impl PermissionSet { self.0 & other.0 == other.0 } - /// Iterates the permissions in the set, in TCF purpose order. + /// Iterates the permissions in the set, in bit-index order. /// /// The built-ins read nothing from the full set; this serves a provider or /// diagnostic path that enumerates what is present. @@ -296,7 +299,7 @@ pub enum Acquisition { /// 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 +/// This module 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 @@ -534,13 +537,15 @@ impl PermissionMaps { }) } - /// Builds the maps from a `permissions.yaml` document: named `groups` and - /// the `rules` that map a country or country/region to a group. + /// Builds the maps from a `permissions.yaml` document: named `groups`, the + /// `rules` that map a country or country/region to a group, and the + /// `signals` section that maps each session signal onto Data Uses. /// /// # Errors /// - /// Returns [`PermissionsError`] when the YAML is malformed or names an - /// unknown group, permission, or acquisition rule. + /// Returns [`PermissionsError`] when the YAML is malformed, names an unknown + /// group, permission, or acquisition flag, maps the same country or region + /// rule twice, or names an unknown Data Use in a signal's revoke list. pub fn from_yaml(yaml: &str) -> Result { let file: RulesFile = serde_yaml_ng::from_str(yaml).map_err(|error| PermissionsError::Parse { @@ -743,7 +748,8 @@ impl PermissionState { #[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. + /// of per-permission flags, with an optional `default` shorthand for any + /// permission it omits. #[serde(default)] groups: BTreeMap>, /// Rules keyed by country (`FR`) or country and region (`US/CA`). @@ -1273,19 +1279,19 @@ rules: "US (us) grants device storage" ); - // CA references the eu group, but +necessary.operations.storage grants it, overriding - // the eu baseline. + // CA references the eu group, but its permissions map grants + // necessary.operations.storage, 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" + "the permissions map grants necessary.operations.storage for CA, overriding the eu baseline" ); - // -advertising_marketing.first_party.contextual denies it: not set even when a signal grants it. + // the permissions map denies advertising_marketing.first_party.contextual: 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" + "the permissions map denies advertising_marketing.first_party.contextual even when a signal grants it" ); // An unmapped country with a default of `US` uses the us (granted) rule. diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 37c1ff7a08..37749317b4 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -42,7 +42,7 @@ 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 Edge Cookie provider only when every permission that provider requires is set. An Edge Cookie provider that requires nothing always runs, so a -vendor-neutral default shows no consent dialogue and needs no per-request +vendor-neutral default needs no consent prompt and no per-request policy interaction. Device and geo providers declare their required permissions through the same @@ -84,7 +84,7 @@ flowchart LR ``` A request's permissions are set by one or more **permission sources**. Consent -is one source among several, not the basis for every permission: +is one source among many, 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 @@ -117,7 +117,7 @@ into the consumer. 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, being purpose 1 (device +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 @@ -178,10 +178,11 @@ 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 +with no country prefix (`US/CA` is California). The Fastly geo provider emits +these codes directly, and any other provider must do the same. 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 diff --git a/permissions.yaml b/permissions.yaml index 1e9e17fa0f..500c31bc19 100644 --- a/permissions.yaml +++ b/permissions.yaml @@ -22,12 +22,16 @@ # 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. +# The Fastly geo provider emits these codes directly, and any other +# provider must do the same. # # 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. +# provider resolves as no location, 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, and startup fails when none is configured. +# A geo provider that reports an outright lookup failure is the exception, as it +# resolves every permission to the requires-signal floor rather than the +# default, though no geo provider shipped today reports one. # # A permission flag is one of: # granted set without any signal (for example strictly necessary) @@ -258,9 +262,10 @@ groups: 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. +# Map a country, or a country and state (country/state), to a group. A rule is +# either a bare group name, or a map with `group:` and a `permissions:` map that +# overrides individual Data Uses (each set to granted, requires_signal or +# denied). The overrides win over the group baseline. rules: # European Union (27). AT: gdpr-eu From 1a73fe6b1512861a680fec4f4ff6f8b4e919ad80 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 12:07:35 +0100 Subject: [PATCH 10/82] Complete the mint and fingerprint house-style sweep after rebase --- crates/trusted-server-core/src/ec/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index bd2b049968..753dddf77f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -358,7 +358,7 @@ impl EcContext { // re-deriving it. let permissions = consent::assemble_permissions(settings, &consent, geo_status); let storage_acquisition = consent::storage_acquisition(settings, geo_status); - // With no provider selected nothing may mint or use an identifier, so + // With no provider selected nothing may create or use an identifier, so // the gate is closed rather than open by default. let ec_allowed = selected_provider .as_ref() @@ -1687,7 +1687,7 @@ mod tests { fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { let mut settings = create_test_settings(); // A granted-baseline default jurisdiction, so the storage permission - // is set with no signal and the mint runs. + // is set with no signal and the creation runs. settings.geo.default_country = Some("US/CA".to_owned()); let req = create_test_request(&[]); let services = crate::platform::test_support::noop_services_with_client_ip( From c793b23dd93b70d95c29989931489eb68a45d958 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 1 Sep 2026 15:31:39 +0100 Subject: [PATCH 11/82] Document how the resolved permissions reach filters and the page --- docs/guide/permission-model.md | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 37749317b4..0c9793093c 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -295,6 +295,61 @@ The "Failed" branch is the rule for a geo provider that can report a failed lookup. None of the providers shipped today can, so a geo outage takes the "No or none resolved" branch instead. See the default-country note below. +## How the resolved permissions reach downstream code + +The permission state is resolved once, at the start of the request cycle in +`EcContext::read_from_request`, before any integration request filter or route +handler runs. Everything downstream reads that one result rather than deriving +its own, so there is a single decision per request. + +**Server side.** An integration request filter receives +`permissions: Option<&PermissionState>` on its `RequestFilterInput`, alongside +the geo result it already receives, so a filter can skip or narrow what it does +when a permission it depends on is unset. None of the shipped filters changes +its behavior on that input yet, so for now the state is carried and available +rather than acted on. + +**Page side.** The same resolved state reaches the page as +`window.tsjs.permissions`, an object listing the Data Use names that are set for +this request. The names are the ones used everywhere else in this guide, being +the `permissions.yaml` keys and `Permission::as_str()`. + +```json +{ + "set": [ + "necessary.operations.storage", + "analytics.ad_reporting.market_research" + ] +} +``` + +Delivery follows the pattern already used for `adSlots` and `bids`, and the +timing depends on how the page is assembled. Under inline assembly the value is +injected as a ``. + /// Injected at `` open, ahead of [`Self::ad_slots_script`] and the + /// tsjs bundle, so page code can read the request's permission state before + /// anything runs. `None` under a shared-template mode, where the head is + /// cached and served to many readers and nothing request-scoped may appear + /// in it, so the seam carries the state there instead. + pub permissions_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -226,6 +234,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + permissions_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, @@ -254,6 +263,17 @@ impl HtmlProcessorConfig { self } + /// Attach the head script carrying this request's permission state. + /// + /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are + /// independent decisions: the permission state travels on every HTML + /// document the processor handles, whether or not the ad stack ran. + #[must_use] + pub fn with_permissions_script(mut self, permissions_script: Option) -> Self { + self.permissions_script = permissions_script; + self + } + /// Set what the `` seam injects. /// /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are @@ -372,6 +392,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); + let permissions_script = config.permissions_script.clone(); let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); @@ -404,10 +425,17 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let patterns = patterns.clone(); let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); + let permissions_script = permissions_script.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The permission state goes first, ahead of the slots and + // the bundle, because both of those and any vendor module + // may read it as soon as they run. + if let Some(ref state_script) = permissions_script { + snippet.push_str(state_script); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -829,6 +857,7 @@ mod tests { request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1805,6 +1834,7 @@ mod tests { r#""# .to_string(), ), + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1882,6 +1912,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1921,6 +1952,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1959,6 +1991,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2015,6 +2048,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2045,6 +2079,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2070,6 +2105,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2206,6 +2242,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index e75ebc1f36..e95f611563 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -738,6 +738,42 @@ impl PermissionState { pub const fn permissions(&self) -> PermissionSet { self.set } + + /// The resolved state as the JSON the page receives in + /// `window.tsjs.permissions`. + /// + /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the + /// same state always serializes to the same bytes whatever order the set + /// was built in. An empty state + /// renders as `{"set":[]}`, which is an answer (nothing is set) rather than + /// a missing value, so page code never has to tell the two apart. + /// + /// This is the only place the page shape is spelled, so no caller writes + /// the JSON by hand. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::permissions::{ + /// Permission, PermissionSet, PermissionState, + /// }; + /// + /// let state = PermissionState::new( + /// PermissionSet::none().with(Permission::StoreOnDevice), + /// ); + /// assert_eq!( + /// state.page_json(), + /// r#"{"set":["necessary.operations.storage"]}"# + /// ); + /// + /// assert_eq!(PermissionState::default().page_json(), r#"{"set":[]}"#); + /// ``` + #[must_use] + pub fn page_json(&self) -> String { + let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); + names.sort_unstable(); + serde_json::json!({ "set": names }).to_string() + } } // --------------------------------------------------------------------------- @@ -966,6 +1002,8 @@ impl core::error::Error for PermissionsError {} #[cfg(test)] mod tests { + use serde_json::json; + use super::*; #[test] @@ -1009,6 +1047,49 @@ mod tests { ); } + #[test] + fn page_json_lists_set_permissions_sorted_by_name() { + // Arrange: a state whose bit-index order is the reverse of its name + // order, so the sort is what the assertion sees. + let state = PermissionState::new( + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds), + ); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": [ + "advertising_marketing.first_party.contextual", + "necessary.operations.storage", + ] + }) + .to_string(), + "should list every set permission by Data Use name, sorted" + ); + } + + #[test] + fn page_json_of_an_empty_state_is_an_empty_set() { + // Arrange + let state = PermissionState::default(); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ "set": [] }).to_string(), + "an empty state should render as an empty set, not as nothing" + ); + } + #[test] fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { // Empty maps and no default: every permission is the requires-signal diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 78d128722c..a9adcf41df 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -65,6 +65,7 @@ use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::IntegrationRegistry; +use crate::permissions::PermissionState; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, VarySpec, contains_publisher_esi_directive, @@ -622,6 +623,9 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, suppress_datadome_client_side_tag: bool, @@ -653,6 +657,7 @@ impl PublisherBodyProcessor { request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -734,6 +739,7 @@ fn process_response_streaming( request_scheme: params.request_scheme, settings: params.settings, integration_registry: params.integration_registry, + permissions_script: params.permissions_script.map(str::to_string), ad_slots_script: params.ad_slots_script.map(str::to_string), ad_bids_state: params.ad_bids_state.clone(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -1230,6 +1236,9 @@ struct HtmlStreamProcessorParams<'a> { request_scheme: &'a str, settings: &'a Settings, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option, ad_slots_script: Option, ad_bids_state: Arc>>, suppress_datadome_client_side_tag: bool, @@ -1384,6 +1393,20 @@ pub(crate) fn body_close_injection( } } +/// The head script this response's permission state belongs in, if any. +/// +/// Derived here rather than carried on [`OwnedProcessResponseParams`] so the state +/// has one representation on the request (the JSON) and the head-or-seam decision is +/// taken from the same effective mode both seams use. Under a shared-template mode +/// the answer is [`None`] and the seam carries the state instead. +fn permissions_script_for( + params: &OwnedProcessResponseParams, + settings: &Settings, +) -> Option { + let mode = effective_assembly_mode(settings, params.template_cache_key.is_some()); + template_permissions_script(mode, ¶ms.permissions_json) +} + fn create_html_stream_processor( params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { @@ -1410,6 +1433,7 @@ fn create_html_stream_processor( .flatten(); let config = config + .with_permissions_script(params.permissions_script) .with_ad_state(params.ad_slots_script, params.ad_bids_state) .with_gpt_diagnostics(gpt_diagnostics) .with_body_close(body_close) @@ -1585,6 +1609,14 @@ pub struct OwnedProcessResponseParams { /// /// Request-scoped, so it travels with the request rather than into the template. pub(crate) seam_ad_slots: Option, + /// This request's resolved permission state as page JSON, from + /// [`PermissionState::page_json`]. + /// + /// Carried as the JSON rather than as a rendered script because it is delivered in + /// two different places: the head under an inline response, and the `` seam + /// under a shared-template one. An empty string means no caller filled it in and + /// renders as the empty state. + pub(crate) permissions_json: String, /// Origin policy headers to store with the template and replay on a hit. pub(crate) policy_headers: Vec<(String, String)>, pub(crate) content_encoding: String, @@ -1996,21 +2028,28 @@ fn response_carries_a_seam_marker(was_authorized: bool, settings: &Settings) -> /// calls `scheduleInitialAdInit`, which schedules `adInit` for precisely the traffic /// that opted out. Absent is not the same as empty here. /// +/// It is never nothing at all any more, because the permission state has to reach the +/// page whether or not the ad stack ran, and a shared template's head cannot carry it. +/// The no-ad-stack answer is [`build_permissions_seam_script`], which sets the state and +/// schedules no ad init. +/// /// Shared by the miss path and by **both** hit finalizers. They previously each spelled /// the decision out, and the two hit paths spelled it `unwrap_or("[]")` — so the gate /// held on a cache miss and was ignored on every cache hit. fn seam_script_for(params: &OwnedProcessResponseParams) -> String { - params - .seam_ad_slots - .as_deref() - .map(|slots| params.ad_bids_state.build_seam_script(slots)) - .unwrap_or_default() + match params.seam_ad_slots.as_deref() { + Some(slots) => params + .ad_bids_state + .build_seam_script(slots, ¶ms.permissions_json), + None => build_permissions_seam_script(¶ms.permissions_json), + } } /// Builds the injection state a cached template needs on the way out. /// -/// The template carries no auction state — that is what makes it shareable — so the -/// per-reader parts are attached here, from this request. +/// The template carries no auction state and no permission state, which is what makes +/// it shareable, so the per-reader parts are attached here, from this request. Both +/// leave through the seam, never through the cached head. fn build_template_assembly_params( entry: &crate::platform::TemplateEntry, settings: &Settings, @@ -2018,6 +2057,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + permissions_json: String, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, @@ -2032,6 +2072,7 @@ fn build_template_assembly_params( request_scheme: request_scheme.to_string(), content_type: entry.metadata.content_type.clone(), // The template already carries the head seam; re-injecting would duplicate it. + permissions_json, ad_slots_script: None, ad_bids_state, auction_observation: None, @@ -2769,6 +2810,7 @@ pub fn stream_publisher_body( settings: &Settings, integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { + let permissions_script = permissions_script_for(params, settings); let borrowed = ProcessResponseParams { content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, @@ -2778,6 +2820,7 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + permissions_script: permissions_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: params.ad_bids_state.script_cell(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -2880,6 +2923,7 @@ pub async fn stream_publisher_body_async( request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -3117,8 +3161,8 @@ impl AdBidsState { } /// Build the shared-template seam, retaining the same debug prefix as inline. - fn build_seam_script(&self, slots_json: &str) -> String { - let seam = build_seam_script(slots_json, &self.bids()); + fn build_seam_script(&self, slots_json: &str, permissions_json: &str) -> String { + let seam = build_seam_script(slots_json, &self.bids(), permissions_json); let prefix = self .debug_prefix .lock() @@ -4092,6 +4136,11 @@ pub async fn handle_publisher_request( .filter(|_| ec_context.ec_sharing_allowed()); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); + // Resolved at the start of the request, so take it here, before the mutable + // borrows further down. Every HTML response carries it to the page, whether the + // ad stack runs or not, so the value is read once and cloned rather than + // recomputed per delivery point. + let permissions_json = ec_context.permissions().page_json(); let parsed_origin = url::Url::parse(&settings.publisher.origin_url).change_context( TrustedServerError::Proxy { @@ -4540,6 +4589,7 @@ pub async fn handle_publisher_request( request_scheme, price_granularity, ad_bids_state.clone(), + permissions_json.clone(), ); params.seam_ad_slots = seam_ad_slots.clone(); params.dispatched_auction = dispatched_auction.take(); @@ -4902,6 +4952,7 @@ pub async fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + permissions_json, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), suppress_datadome_client_side_tag, @@ -5420,6 +5471,7 @@ else t.bids=b;\ pub(crate) fn build_seam_script( slots_json: &str, bid_map: &serde_json::Map, + permissions_json: &str, ) -> String { // The local test script probes the minified `var a=JSON.parse`, // `var b=JSON.parse`, and `s(b,a)` literals below. Update the harness with any @@ -5429,17 +5481,40 @@ pub(crate) fn build_seam_script( format!( "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)), html_escape_for_script(slots_json), html_escape_for_script(&bids) ) } +/// Build the `` seam script for a request whose ad stack did not run. +/// +/// The head of a shared template carries nothing request-scoped, so the seam is +/// the only place this reader's permission state can be delivered. Before this +/// existed the seam was empty whenever the ad stack was skipped, which left a +/// bot-classified or permission-denied visitor with no state on the page at all. +/// +/// Carries the state and nothing else. It deliberately does not set `adSlots` or +/// `bids` and does not call `scheduleInitialAdInit`, because scheduling `adInit` +/// for traffic that opted out is what the gate in [`seam_script_for`] exists to +/// prevent. +pub(crate) fn build_permissions_seam_script(permissions_json: &str) -> String { + format!( + "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)) + ) +} + /// The slot definitions a shared-mode seam must carry, as JSON. /// /// Mirrors [`template_ad_slots_script`]'s gating: same `should_run_ad_stack` condition, @@ -6126,6 +6201,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 0000000000..555b4e912f --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2490 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "build-print" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "thiserror", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.4", + "toml", + "validator", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iab_gpp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.119", + "thiserror", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissions-inspector-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "trusted-server-core", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-core" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "base64", + "brotli", + "bytes", + "chacha20poly1305", + "chrono", + "cookie", + "derive_more", + "ed25519-dalek", + "edgezero-core", + "error-stack", + "flate2", + "futures", + "getrandom 0.2.17", + "glob", + "hex", + "hmac", + "http", + "httpdate", + "iab_gpp", + "jose-jwk", + "log", + "lol_html", + "matchit", + "mime", + "rand", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "toml", + "trusted-server-js", + "trusted-server-openrtb", + "url", + "urlencoding", + "uuid", + "validator", + "web-time", +] + +[[package]] +name = "trusted-server-js" +version = "0.1.0" +dependencies = [ + "build-print", + "hex", + "sha2", + "which", +] + +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 0000000000..f212c89dbf --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "permissions-inspector-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trusted-server-core = { path = "../../../crates/trusted-server-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 0000000000..65abf8bda7 --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,37 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 0000000000..928df80b1c --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,151 @@ +//! The permissions bit of Trusted Server, compiled to WebAssembly for the +//! inspector page. Inputs in, resulting permissions out, through the same +//! functions the server runs: `build_context_from_signals` decodes the raw +//! consent signals and `assemble_permissions` resolves the policy. + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The inspector's evaluation request. +#[derive(Deserialize)] +struct EvalInput { + /// `located`, `none`, or `failed`. + geo: String, + country: Option, + region: Option, + tc: Option, + gpp: Option, + us_privacy: Option, + #[serde(default)] + gpc: bool, +} + +fn eval_json(input: &str) -> String { + let input: EvalInput = match serde_json::from_str(input) { + Ok(input) => input, + Err(e) => return json!({"ok": false, "error": e.to_string()}).to_string(), + }; + let signals = RawConsentSignals { + raw_tc_string: input.tc.filter(|s| !s.is_empty()), + raw_gpp_string: input.gpp.filter(|s| !s.is_empty()), + raw_gpp_sid: None, + raw_us_privacy: input.us_privacy.filter(|s| !s.is_empty()), + gpc: input.gpc, + }; + let ctx = build_context_from_signals(&signals); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&ctx, GeoStatus::Failed); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + (state, jurisdiction_name(maps.default_jurisdiction())) + } + _ => { + let info = GeoInfo { + city: String::new(), + country: input.country.clone().unwrap_or_default(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: input.region.clone().filter(|r| !r.is_empty()), + asn: None, + }; + let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let jurisdiction = jurisdiction_name( + maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), + ); + (state, jurisdiction) + } + }; + let set: Vec<&'static str> = Permission::all() + .filter(|p| state.is_set(*p)) + .map(Permission::as_str) + .collect(); + json!({ + "ok": true, + "jurisdiction": jurisdiction, + "set": set, + "tcf_decoded": ctx.tcf.is_some(), + "malformed_record": ctx.has_malformed_record(), + }) + .to_string() +} + +fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { + let name = format!("{j:?}").to_lowercase(); + let name = name.split('(').next().unwrap_or(&name).to_string(); + name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") +} + +fn validate_json(yaml: &str) -> String { + match PermissionMaps::from_yaml(yaml) { + Ok(_) => json!({"ok": true}).to_string(), + Err(e) => json!({"ok": false, "error": e.to_string()}).to_string(), + } +} + +fn meta_json() -> String { + json!({ + "version": env!("TS_CORE_VERSION"), + "commit": env!("TS_CORE_COMMIT"), + "date": env!("TS_CORE_DATE"), + "branch": env!("TS_CORE_BRANCH"), + }) + .to_string() +} + +/// Leaks a length-prefixed buffer the host reads and then frees. +fn out(s: String) -> *mut u8 { + let bytes = s.into_bytes(); + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&bytes); + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_alloc(len: usize) -> *mut u8 { + let mut buf = vec![0u8; len]; + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must come from `ts_alloc` or an `out` buffer with capacity `len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_free(ptr: *mut u8, len: usize) { + unsafe { drop(Vec::from_raw_parts(ptr, len, len)) }; +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 JSON buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_eval(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(eval_json(core::str::from_utf8(input).unwrap_or("{}"))) +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 YAML buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_validate(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(validate_json(core::str::from_utf8(input).unwrap_or(""))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_meta() -> *mut u8 { + out(meta_json()) +} From 069ec9d11aa3b45df6c4ed42f562526d2228ac02 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 17:38:53 +0100 Subject: [PATCH 30/82] Mark the inspector build script executable CI failed with permission denied because the executable bit was never recorded in git, the usual Windows-authored-script gap. --- scripts/build-inspector-wasm.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build-inspector-wasm.sh diff --git a/scripts/build-inspector-wasm.sh b/scripts/build-inspector-wasm.sh old mode 100644 new mode 100755 From 9909f2e0cfe2ce32fad44967b429e6cae460f830 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 17:54:55 +0100 Subject: [PATCH 31/82] Limit the inspector workflow token to reading contents The code-scanning bot flagged the new workflow for carrying the default GITHUB_TOKEN permissions. The job only checks out and builds, so it reads contents and nothing else. --- .github/workflows/inspector.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml index 7b167d38c5..5d58b3d93e 100644 --- a/.github/workflows/inspector.yml +++ b/.github/workflows/inspector.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: build-inspector-wasm: name: build inspector wasm From aa0bce01d9091f48dcf3012ea68de658db3cdeb2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 20:35:36 +0100 Subject: [PATCH 32/82] Point the compiled-in rules doc at config/permissions The doc comment above the include_str still said the file lives at the repository root after the move to config/permissions/vanilla.yaml. --- crates/trusted-server-core/src/permissions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 00205b1f2a..1c5dabc840 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -499,9 +499,9 @@ pub struct PermissionMaps { signals: SignalPolicy, } -/// The default permission rules, compiled into the build from the human-editable -/// `permissions.yaml` at the repository root. A deployer edits or replaces that -/// file to change the default policy; it is not read at runtime. +/// The default permission rules, compiled into the build from the repository's +/// vanilla sample in `config/permissions`. A deployer chooses or replaces the +/// compiled-in file to set the default policy; it is not read at runtime. const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/vanilla.yaml"); /// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. From 02f79c7515c73736338fafd691b1b28a7980a8f0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 14:03:17 +0100 Subject: [PATCH 33/82] Put the permission signal models behind a seam The permission model treats permissions as the primitive and says consent is only one of several ways a permission is established. The sources did not follow. TCF, a malformed record and the US-style opt-outs were decoded inline in one function, so a fifth way of learning a permission could not exist without editing core. PermissionSignalSource is that seam. It differs from the geo, device and identity seams in one way that matters: those select one implementation, because a request has one country and one device answer, while signals compose, because a request can carry a TCF string and a Global Privacy Control header at once and both have something to say. So the combining rule is written down rather than implied by the order of three if statements. A refusal beats a grant, a grant beats silence, and silence leaves the place baseline standing. That is not new policy: it is exactly the precedence the inline version had, where an opt-out revoked whatever TCF said and an absent signal changed nothing. Writing it as a rule is the point, because a rule takes a fourth source without being rewritten. The three existing models now sit behind the trait unchanged and core composes them itself, so a deployment configuring nothing behaves exactly as before. That was the bar for this change and 2,404 core tests plus the doctests say it is met. SignalPolicy and its accessors become public, because a seam meant to be implemented outside core has to hand a source something it can read. Clippy found that rather than a reviewer, by pointing out that a public field exposed a crate-private type. One thing nearly went wrong and is worth recording. The first draft had the TCF source return Neutral where the original returned Revoke for a purpose the visitor had not consented to. Silence would have left the place baseline standing and granted what they declined. An assertion on the exact original text caught it before it compiled, which is the argument for matching code you are replacing rather than retyping it from memory. Silence and refusal being different is now stated in the trait contract, because a source that confused them would revoke every permission it had no opinion on, on every request that did not carry its model. Verified: 2,404 core tests, 7 doctests, clippy clean at -D warnings, cargo fmt clean. --- crates/trusted-server-core/src/consent/mod.rs | 1 + .../trusted-server-core/src/consent/source.rs | 178 ++++++++++++++++++ crates/trusted-server-core/src/ec/consent.rs | 115 +++++++++-- crates/trusted-server-core/src/permissions.rs | 16 +- 4 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 crates/trusted-server-core/src/consent/source.rs diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index df98bd7aa0..cde2a462a2 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -34,6 +34,7 @@ mod extraction; pub mod gpp; pub mod jurisdiction; +pub mod source; pub mod tcf; pub mod types; pub mod us_privacy; diff --git a/crates/trusted-server-core/src/consent/source.rs b/crates/trusted-server-core/src/consent/source.rs new file mode 100644 index 0000000000..94556a2e83 --- /dev/null +++ b/crates/trusted-server-core/src/consent/source.rs @@ -0,0 +1,178 @@ +//! Where a permission signal comes from. +//! +//! # Why this is a seam +//! +//! Permissions are the primitive, and consent is only one of several ways a +//! permission is established. The permission model says so and the language +//! throughout enforces it. The **sources** did not follow: TCF, GPP, US Privacy +//! and Global Privacy Control were decoded inside core and reachable only +//! through one struct, so a fifth way of learning a permission could not exist +//! without changing core. +//! +//! That is the same closed list the provider work already opened for geo, +//! device detection and Edge Cookie identity. This opens it for signals. +//! +//! # How sources differ from providers +//! +//! Geo, device and identity **select** one implementation: a request has one +//! country, one device answer, one identifier. Signals **compose**: a request +//! can carry a TCF string and a Global Privacy Control header at once, and both +//! have something to say. So a deployment collects a list rather than choosing +//! one, and [`combine`] decides what the list means together. +//! +//! # The combining rule +//! +//! A refusal beats a grant, and a grant beats silence: +//! +//! 1. Any source saying [`ConsentSignal::Revoke`] decides the answer. +//! 2. Otherwise any source saying [`ConsentSignal::Grant`] decides it. +//! 3. Otherwise [`ConsentSignal::Neutral`], and the place baseline stands. +//! +//! This is not a new policy. It is the precedence the hard-coded version +//! already had, written down: an opt-out revoked whatever TCF said, a malformed +//! record revoked, and an absent signal left the baseline alone. Expressing it +//! as a rule rather than as the order of three `if` statements is the point, +//! because a rule can take a fourth source without being rewritten. +//! +//! A source that has nothing to say about a permission returns `Neutral`, which +//! is different from `Revoke`. Silence is not refusal, and a source that +//! confused the two would revoke every permission it had no opinion on. + +use std::sync::Arc; + +use crate::permissions::{ConsentSignal, Permission, SignalPolicy}; + +use super::ConsentContext; + +/// What a signal source may read about a request. +/// +/// Deliberately a struct rather than a parameter list, so a source that needs +/// something new does not change every implementation. Today that is the +/// decoded consent record and the policy; a source reading a cookie of its own +/// will need the request, and adding it here will not disturb the four below. +pub struct SignalInput<'a> { + /// The decoded consent record for this request. + pub consent: &'a ConsentContext, + /// The policy from `permissions.yaml`, which decides what a signal means + /// rather than leaving each source to invent its own meaning. + pub policy: &'a SignalPolicy, +} + +/// A source of permission signals. +/// +/// An implementation answers for one signalling model. It reads the request, +/// applies whatever the policy says about its own model, and returns what it +/// knows about one permission. +/// +/// # Contract +/// +/// Answer `Neutral` for a permission this source has no opinion on, including +/// when the signal it reads is absent from the request. Returning `Revoke` for +/// an absent signal would turn silence into refusal and revoke every permission +/// on every request that did not carry this model. +pub trait PermissionSignalSource: Send + Sync { + /// Stable identifier, used in configuration and logs. + fn id(&self) -> &'static str; + + /// What this source knows about `permission` for this request. + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; +} + +/// The answer of several sources taken together. +/// +/// See the module documentation for the rule and why it is the existing +/// precedence rather than a new policy. +#[must_use] +pub fn combine( + sources: &[Arc], + permission: Permission, + input: &SignalInput<'_>, +) -> ConsentSignal { + let mut granted = false; + for source in sources { + match source.signal(permission, input) { + // A refusal is final and there is no point asking the rest. + ConsentSignal::Revoke => return ConsentSignal::Revoke, + ConsentSignal::Grant => granted = true, + ConsentSignal::Neutral => {} + } + } + if granted { + ConsentSignal::Grant + } else { + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A source that always answers the same thing, for testing the rule + /// rather than any particular model. + struct Fixed(&'static str, ConsentSignal); + + impl PermissionSignalSource for Fixed { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + self.1 + } + } + + fn sources(signals: &[ConsentSignal]) -> Vec> { + signals + .iter() + .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .collect() + } + + fn combined(signals: &[ConsentSignal]) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let input = SignalInput { + consent: &consent, + policy: &policy, + }; + combine(&sources(signals), Permission::StoreOnDevice, &input) + } + + #[test] + fn no_sources_leaves_the_place_baseline_alone() { + assert_eq!(combined(&[]), ConsentSignal::Neutral); + } + + #[test] + fn a_refusal_beats_a_grant_whatever_the_order() { + assert_eq!( + combined(&[ConsentSignal::Grant, ConsentSignal::Revoke]), + ConsentSignal::Revoke + ); + assert_eq!( + combined(&[ConsentSignal::Revoke, ConsentSignal::Grant]), + ConsentSignal::Revoke, + "an opt-out revoked whatever TCF said, and that has to survive the refactor" + ); + } + + #[test] + fn a_grant_beats_silence() { + assert_eq!( + combined(&[ConsentSignal::Neutral, ConsentSignal::Grant]), + ConsentSignal::Grant + ); + } + + #[test] + fn silence_from_every_source_is_not_a_refusal() { + // The failure this guards is a source that reads an absent signal as a + // refusal. It would revoke every permission on every request that did + // not carry that model, which is most of them. + assert_eq!( + combined(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), + ConsentSignal::Neutral + ); + } +} diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index f87d811d53..917b28dda9 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -10,6 +10,9 @@ use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; +use std::sync::Arc; + +use crate::consent::source::{PermissionSignalSource, SignalInput}; use crate::permissions::{ Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -157,30 +160,110 @@ fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, ) -> impl Fn(Permission) -> ConsentSignal + 'a { + let sources = builtin_sources(); move |permission| { - if opt_out_present(consent, signals.opt_out_sources()) - && signals.opt_out_revokes(permission) + let input = SignalInput { + consent, + policy: signals, + }; + crate::consent::source::combine(&sources, permission, &input) + } +} + +/// The signal models core supplies itself. +/// +/// These four were decoded inline until the seam existed. They are the same +/// rules, moved behind [`PermissionSignalSource`] so a fifth model can be added +/// by a module instead of by editing core. A deployment that configures nothing +/// gets exactly these, in this order, which is why the refactor is invisible. +/// +/// The order does not decide the outcome, because [`combine`] lets a refusal +/// win wherever it appears. It is kept as it was for readability. +/// +/// [`combine`]: crate::consent::source::combine +/// [`PermissionSignalSource`]: crate::consent::source::PermissionSignalSource +fn builtin_sources() -> Vec> { + vec![ + Arc::new(UsOptOutSource), + Arc::new(MalformedRecordSource), + Arc::new(TcfSource), + ] +} + +/// A US-style opt-out, from Global Privacy Control, a GPP sale opt-out, or a +/// US Privacy string. +/// +/// Which of those count, and what an opt-out takes away, are both the policy's +/// decisions rather than this source's. It only reads whether one is present. +struct UsOptOutSource; + +impl PermissionSignalSource for UsOptOutSource { + fn id(&self) -> &'static str { + "us-opt-out" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if opt_out_present(input.consent, input.policy.opt_out_sources()) + && input.policy.opt_out_revokes(permission) { return ConsentSignal::Revoke; } - if consent.has_malformed_record() { + ConsentSignal::Neutral + } +} + +/// A consent record that arrived and could not be read. +/// +/// Distinct from no record at all. An absent record is silence and leaves the +/// place baseline standing. A record that is present and malformed is a signal +/// we cannot trust, so it revokes rather than being ignored. +struct MalformedRecordSource; + +impl PermissionSignalSource for MalformedRecordSource { + fn id(&self) -> &'static str { + "malformed-record" + } + + fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if input.consent.has_malformed_record() { return ConsentSignal::Revoke; } - if signals.tcf_authoritative() - && let Some(tcf) = crate::consent::effective_tcf(consent) - { - return match signals.tcf_purpose(permission) { - Some(purpose) => { - if tcf.has_purpose_consent(usize::from(purpose)) { - ConsentSignal::Grant - } else { - ConsentSignal::Revoke - } + ConsentSignal::Neutral + } +} + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is the policy's, so this source +/// decodes and does not interpret. A permission no purpose maps to gets +/// silence, not a refusal. +struct TcfSource; + +impl PermissionSignalSource for TcfSource { + fn id(&self) -> &'static str { + "tcf" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(tcf) = crate::consent::effective_tcf(input.consent) else { + return ConsentSignal::Neutral; + }; + match input.policy.tcf_purpose(permission) { + Some(purpose) => { + if tcf.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal, + // not silence. Reading it as silence would leave the place + // baseline standing and grant what they declined. + ConsentSignal::Revoke } - None => ConsentSignal::Neutral, - }; + } + None => ConsentSignal::Neutral, } - ConsentSignal::Neutral } } diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1c5dabc840..1d441530c1 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -384,7 +384,7 @@ enum RevokeSet { /// it says only how a decoded signal grants or revokes each Data Use, and the /// country/region baseline decides the rest. #[derive(Debug, Clone, Default)] -pub(crate) struct SignalPolicy { +pub struct SignalPolicy { /// Whether a present TCF record's grants and revokes apply. This never /// lets a TCF record override an opt-out signal: an opt-out always /// suppresses the Data Uses it revokes. @@ -399,23 +399,27 @@ pub(crate) struct SignalPolicy { impl SignalPolicy { /// Whether a present TCF record's grants and revokes apply. - pub(crate) fn tcf_authoritative(&self) -> bool { + #[must_use] + pub fn tcf_authoritative(&self) -> bool { self.tcf_authoritative } /// The TCF purpose number that grants `permission`, or `None` when no purpose /// maps to it. - pub(crate) fn tcf_purpose(&self, permission: Permission) -> Option { + #[must_use] + pub fn tcf_purpose(&self, permission: Permission) -> Option { self.tcf_purpose.get(&permission.index()).copied() } /// The signals that constitute a US-style opt-out. - pub(crate) fn opt_out_sources(&self) -> &[OptOutSource] { + #[must_use] + pub fn opt_out_sources(&self) -> &[OptOutSource] { &self.opt_out_sources } /// Whether a US-style opt-out revokes `permission`. - pub(crate) fn opt_out_revokes(&self, permission: Permission) -> bool { + #[must_use] + pub fn opt_out_revokes(&self, permission: Permission) -> bool { match &self.opt_out_revokes { RevokeSet::None => false, RevokeSet::All => true, @@ -945,7 +949,7 @@ impl Default for RevokeSpec { /// A single US-style opt-out signal source. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OptOutSource { +pub enum OptOutSource { /// The `Sec-GPC` request header (Global Privacy Control). Gpc, /// A GPP US sale opt-out. From da4ea02e57c5c2c0c13c325d0c9f23f539908b2d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 14:56:59 +0100 Subject: [PATCH 34/82] Evaluate permission signals in order, each amending the last The seam added in the previous commit merged every source's answer with a refusal winning wherever it appeared, so the order of the list carried no meaning and a later signal could never amend an earlier one. That made a prompt answer unable to supersede a browser setting, which is the case the seam exists for. Permissions now resolve in layers. The country and region rules give the baseline, then each source is asked in configured order and amends what the ones before it settled on. A source with no opinion returns Neutral and leaves the prior value standing, which stays distinct from refusing. A source is given the baseline, the settled value, the whole ordered list and its own position in it, so it can look a peer up by name, tell a peer that is absent from one that is silent, and ask a peer directly what that peer makes of a permission. That is what makes a rule such as "personalisation is off, but only because Global Privacy Control set it, so my answer supersedes it" expressible by whichever source wants it, rather than fixed here. A consultation goes one level deep, so two sources that consult each other settle instead of recursing. Renamed out from under consent to permission_signal. Global Privacy Control is a setting a browser sends rather than an answer anyone gave to a question, so consent is one kind of signal arriving here and not the name for all of them. The README is the module documentation, so there is one copy of the explanation for the vendor crates to point at. Three tests asserted the opposite rule, that an opt-out suppressed storage whatever else the request carried. That rule was written in a comment beside them and nowhere else; the only specification covering precedence, docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md, sets out a fixed chain and says a publisher needing different precedence should raise it as a configuration change. They now assert the ordered rule. resolve_with, floor_with and resolve_rules pass the baseline to the signal closure, since a source cannot amend what it cannot see. cargo fmt clean, clippy clean on fastly, axum, cloudflare, cloudflare-wasm and spin-native, 2410 core tests, 166 fastly, 40 axum. --- crates/trusted-server-core/src/consent/mod.rs | 1 - .../trusted-server-core/src/consent/source.rs | 178 -------- crates/trusted-server-core/src/ec/consent.rs | 97 ++--- crates/trusted-server-core/src/lib.rs | 1 + .../src/permission_signal/README.md | 91 ++++ .../src/permission_signal/mod.rs | 410 ++++++++++++++++++ crates/trusted-server-core/src/permissions.rs | 30 +- 7 files changed, 566 insertions(+), 242 deletions(-) delete mode 100644 crates/trusted-server-core/src/consent/source.rs create mode 100644 crates/trusted-server-core/src/permission_signal/README.md create mode 100644 crates/trusted-server-core/src/permission_signal/mod.rs diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index cde2a462a2..df98bd7aa0 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -34,7 +34,6 @@ mod extraction; pub mod gpp; pub mod jurisdiction; -pub mod source; pub mod tcf; pub mod types; pub mod us_privacy; diff --git a/crates/trusted-server-core/src/consent/source.rs b/crates/trusted-server-core/src/consent/source.rs deleted file mode 100644 index 94556a2e83..0000000000 --- a/crates/trusted-server-core/src/consent/source.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Where a permission signal comes from. -//! -//! # Why this is a seam -//! -//! Permissions are the primitive, and consent is only one of several ways a -//! permission is established. The permission model says so and the language -//! throughout enforces it. The **sources** did not follow: TCF, GPP, US Privacy -//! and Global Privacy Control were decoded inside core and reachable only -//! through one struct, so a fifth way of learning a permission could not exist -//! without changing core. -//! -//! That is the same closed list the provider work already opened for geo, -//! device detection and Edge Cookie identity. This opens it for signals. -//! -//! # How sources differ from providers -//! -//! Geo, device and identity **select** one implementation: a request has one -//! country, one device answer, one identifier. Signals **compose**: a request -//! can carry a TCF string and a Global Privacy Control header at once, and both -//! have something to say. So a deployment collects a list rather than choosing -//! one, and [`combine`] decides what the list means together. -//! -//! # The combining rule -//! -//! A refusal beats a grant, and a grant beats silence: -//! -//! 1. Any source saying [`ConsentSignal::Revoke`] decides the answer. -//! 2. Otherwise any source saying [`ConsentSignal::Grant`] decides it. -//! 3. Otherwise [`ConsentSignal::Neutral`], and the place baseline stands. -//! -//! This is not a new policy. It is the precedence the hard-coded version -//! already had, written down: an opt-out revoked whatever TCF said, a malformed -//! record revoked, and an absent signal left the baseline alone. Expressing it -//! as a rule rather than as the order of three `if` statements is the point, -//! because a rule can take a fourth source without being rewritten. -//! -//! A source that has nothing to say about a permission returns `Neutral`, which -//! is different from `Revoke`. Silence is not refusal, and a source that -//! confused the two would revoke every permission it had no opinion on. - -use std::sync::Arc; - -use crate::permissions::{ConsentSignal, Permission, SignalPolicy}; - -use super::ConsentContext; - -/// What a signal source may read about a request. -/// -/// Deliberately a struct rather than a parameter list, so a source that needs -/// something new does not change every implementation. Today that is the -/// decoded consent record and the policy; a source reading a cookie of its own -/// will need the request, and adding it here will not disturb the four below. -pub struct SignalInput<'a> { - /// The decoded consent record for this request. - pub consent: &'a ConsentContext, - /// The policy from `permissions.yaml`, which decides what a signal means - /// rather than leaving each source to invent its own meaning. - pub policy: &'a SignalPolicy, -} - -/// A source of permission signals. -/// -/// An implementation answers for one signalling model. It reads the request, -/// applies whatever the policy says about its own model, and returns what it -/// knows about one permission. -/// -/// # Contract -/// -/// Answer `Neutral` for a permission this source has no opinion on, including -/// when the signal it reads is absent from the request. Returning `Revoke` for -/// an absent signal would turn silence into refusal and revoke every permission -/// on every request that did not carry this model. -pub trait PermissionSignalSource: Send + Sync { - /// Stable identifier, used in configuration and logs. - fn id(&self) -> &'static str; - - /// What this source knows about `permission` for this request. - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; -} - -/// The answer of several sources taken together. -/// -/// See the module documentation for the rule and why it is the existing -/// precedence rather than a new policy. -#[must_use] -pub fn combine( - sources: &[Arc], - permission: Permission, - input: &SignalInput<'_>, -) -> ConsentSignal { - let mut granted = false; - for source in sources { - match source.signal(permission, input) { - // A refusal is final and there is no point asking the rest. - ConsentSignal::Revoke => return ConsentSignal::Revoke, - ConsentSignal::Grant => granted = true, - ConsentSignal::Neutral => {} - } - } - if granted { - ConsentSignal::Grant - } else { - ConsentSignal::Neutral - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A source that always answers the same thing, for testing the rule - /// rather than any particular model. - struct Fixed(&'static str, ConsentSignal); - - impl PermissionSignalSource for Fixed { - fn id(&self) -> &'static str { - self.0 - } - - fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { - self.1 - } - } - - fn sources(signals: &[ConsentSignal]) -> Vec> { - signals - .iter() - .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) - .collect() - } - - fn combined(signals: &[ConsentSignal]) -> ConsentSignal { - let consent = ConsentContext::default(); - let policy = SignalPolicy::default(); - let input = SignalInput { - consent: &consent, - policy: &policy, - }; - combine(&sources(signals), Permission::StoreOnDevice, &input) - } - - #[test] - fn no_sources_leaves_the_place_baseline_alone() { - assert_eq!(combined(&[]), ConsentSignal::Neutral); - } - - #[test] - fn a_refusal_beats_a_grant_whatever_the_order() { - assert_eq!( - combined(&[ConsentSignal::Grant, ConsentSignal::Revoke]), - ConsentSignal::Revoke - ); - assert_eq!( - combined(&[ConsentSignal::Revoke, ConsentSignal::Grant]), - ConsentSignal::Revoke, - "an opt-out revoked whatever TCF said, and that has to survive the refactor" - ); - } - - #[test] - fn a_grant_beats_silence() { - assert_eq!( - combined(&[ConsentSignal::Neutral, ConsentSignal::Grant]), - ConsentSignal::Grant - ); - } - - #[test] - fn silence_from_every_source_is_not_a_refusal() { - // The failure this guards is a source that reads an absent signal as a - // refusal. It would revoke every permission on every request that did - // not carry that model, which is most of them. - assert_eq!( - combined(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), - ConsentSignal::Neutral - ); - } -} diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 917b28dda9..2ab0b747e4 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -12,7 +12,7 @@ use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; use std::sync::Arc; -use crate::consent::source::{PermissionSignalSource, SignalInput}; +use crate::permission_signal::{PermissionSignalSource, SignalInput}; use crate::permissions::{ Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -136,52 +136,38 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { /// only decodes the request and applies that policy, so no signal-to-permission /// policy lives in the code. /// -/// It considers every source the policy names: a TCF record (a standalone TC -/// string or the EU TCF section of a GPP string), and the US-style opt-out -/// signals (GPC, a GPP sale opt-out, or a US Privacy opt-out). Precedence is -/// most-restrictive-first and is fixed in code, not policy: +/// The models it asks are the ones core supplies (see [`builtin_sources`]), +/// each of which amends what the ones before it settled on. The order is the +/// policy, and [`combine`] documents why. This function only assembles the +/// list and hands each source the request. /// -/// 1. A US-style opt-out revokes the Data Uses the policy lists, even when a -/// TCF record consents. An opt-out is an explicit user signal, so no other -/// signal may override it. -/// 2. A consent record that is present but cannot be decoded revokes -/// everything, so an unreadable expression of preference fails closed -/// instead of degrading to the no-signal baseline. -/// 3. When the policy marks TCF authoritative, a present TCF record then -/// decides the mapped Data Uses: granted where the record consents to the -/// mapped purpose, revoked where it does not, and neutral where no purpose -/// is mapped. The `authoritative` flag governs only whether TCF grants and -/// revokes apply, never whether an opt-out may be overridden. +/// Whether an amendment changes anything is then decided by the country/region +/// map, which drops a `granted` baseline on a `Revoke` and has nothing to drop +/// where the permission is `requires_signal` or `denied`. /// -/// Whether a `Revoke` changes anything is decided by the country/region map, -/// which drops a `granted` baseline and has nothing to drop where the -/// permission is `requires_signal` or `denied`. +/// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, -) -> impl Fn(Permission) -> ConsentSignal + 'a { +) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { let sources = builtin_sources(); - move |permission| { - let input = SignalInput { - consent, - policy: signals, - }; - crate::consent::source::combine(&sources, permission, &input) + move |permission, baseline| { + crate::permission_signal::combine(&sources, permission, consent, signals, baseline) } } /// The signal models core supplies itself. /// -/// These four were decoded inline until the seam existed. They are the same -/// rules, moved behind [`PermissionSignalSource`] so a fifth model can be added -/// by a module instead of by editing core. A deployment that configures nothing -/// gets exactly these, in this order, which is why the refactor is invisible. +/// These were decoded inline until the seam existed. They are the same rules, +/// moved behind [`PermissionSignalSource`] so a further model can be added by a +/// module instead of by editing core. /// -/// The order does not decide the outcome, because [`combine`] lets a refusal -/// win wherever it appears. It is kept as it was for readability. +/// The order runs the signals needing no interaction before the ones following +/// a prompt, so a visitor who arrives with an opt-out and then answers a prompt +/// has their answer applied. A deployment wanting the opposite puts the opt-out +/// source last. /// -/// [`combine`]: crate::consent::source::combine -/// [`PermissionSignalSource`]: crate::consent::source::PermissionSignalSource +/// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource fn builtin_sources() -> Vec> { vec![ Arc::new(UsOptOutSource), @@ -401,15 +387,24 @@ mod tests { } // ------------------------------------------------------------------ - // Opt-out precedence pinning tests. These reinstate the behavior the - // consent module enforced before the permission model: an explicit - // opt-out signal suppresses storage and sharing even when a TCF record - // consents. The permission model must never let a CMP-written record - // override the visitor's own opt-out. + // Opt-out and prompt precedence. + // + // These replace an earlier set asserting the opposite, that an opt-out + // suppressed storage and sharing whatever else the request carried. The + // sources are now asked in order and each amends what the ones before it + // settled, so a later source can amend an opt-out. + // + // The default order asks the signals needing no interaction first and the + // ones following a prompt after, which is why a visitor who arrives with + // an opt-out and then answers a prompt has their answer applied. A + // deployment wanting the opposite puts the opt-out source last. + // + // The layering, the configuration and what a source may consult are in + // crates/trusted-server-core/src/permission_signal/README.md. // ------------------------------------------------------------------ #[test] - fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_gpc_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), gpc: true, @@ -418,14 +413,14 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "GPC should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" ); } #[test] - fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), us_privacy: Some(crate::consent::types::UsPrivacy { @@ -439,14 +434,14 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "a US Privacy opt-out should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under the \ + default order the answer they gave amends the signal they sent" ); } #[test] - fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { let consent = ConsentContext { tcf: Some(tcf_with_purposes(&[1, 4])), gpp: Some(crate::consent::types::GppConsent { @@ -460,9 +455,9 @@ mod tests { let geo = us_ca_geo(); let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "a GPP sale opt-out should suppress storage and sharing even when the TCF record consents" + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under the \ + default order the answer they gave amends the signal they sent" ); } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 31bc119aec..47c0d5e85b 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod permission_signal; pub mod permissions; pub mod platform; pub mod price_bucket; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md new file mode 100644 index 0000000000..ddffce6f37 --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -0,0 +1,91 @@ +# Permission signals + +A permission is the primitive. This is the seam that decides whether one is +set, and it is open so that a new way of learning a permission can be added +without changing core. + +"Signal" rather than "consent" because consent is only one of the things +arriving here. Global Privacy Control is a setting a browser sends, not an +answer anyone gave to a question, and a jurisdiction rule is neither. Consent +is one kind of signal, so the seam takes the wider name and the consent +subsystem keeps the narrower one. + +## The hierarchy + +Permissions are resolved in layers, each amending the one before. + +```text + country / region rules the baseline: granted, requires-signal, denied + | + v + source 1 (configured order) may amend + | + v + source 2 may amend + | + v + ... may amend + | + v + the permission state for this request +``` + +The baseline comes from `permissions.yaml`, keyed by country and region, with +a default for a request whose place is unknown. A geo provider supplies the +place. No geo provider means no country, and the baseline falls to its floor. + +Sources are then asked in order. Each sees what the sources before it settled +on and may amend it. A source with no opinion returns `Neutral` and leaves the +prior value standing, which is different from refusing. + +## The order is the policy + +The last source with an opinion decides, so the order is the policy. It is a +deployment's to set, not this code's to assume. + +Today the order is the order of the list handed to `combine`, which core +assembles itself. Naming the sources in operator configuration arrives with +the split into vendor crates, because a name in a configuration file is only +useful once a source can come from outside core. + +The default order asks the signals needing no interaction first and the ones +following a prompt after. Global Privacy Control withdraws personalisation on +arrival, and a visitor who then answers a prompt has their answer applied over +it. A deployment wanting the opposite puts the opt-out source last. + +Trusted Server takes no view on which model should win. That is a question +about a jurisdiction and a publisher. + +## A source can see the others + +Amending well sometimes needs to know who set the prior value. A source is +given the whole ordered list and its own position in it, so it can look up a +peer by name, see whether a peer it cares about is configured at all, and ask +a peer directly what that peer makes of a permission. + +That is what makes a rule like "personalisation is off, but only because +Global Privacy Control set it, so my answer supersedes it" expressible. The +rule itself belongs to whichever source wants it. This seam only makes the +information available. + +Consulting a peer goes one level deep. A source answering a consultation +cannot consult in turn, so two sources asking each other cannot loop. + +## Writing a source + +Answer `Neutral` for a permission the source has no opinion on, including when +the signal it reads is absent from the request. Returning `Revoke` for an +absent signal turns silence into refusal and would revoke the permission on +every request not carrying that model, which is most of them. + +Read the policy for what a signal means rather than inventing a meaning. The +mapping from a permission to a TCF purpose, and which signals count as an +opt-out, are the policy's decisions so that a deployment can change them +without changing a source. + +## The models supplied + +Core supplies the models it already understood: a US-style opt-out (Global +Privacy Control, a GPP sale opt-out, or a US Privacy string), a consent record +that arrived unreadable, and TCF v2. A deployment configuring nothing gets +those, in that order. diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs new file mode 100644 index 0000000000..f8fac0af07 --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -0,0 +1,410 @@ +#![doc = include_str!("README.md")] + +use std::sync::Arc; + +use crate::consent::ConsentContext; +use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; + +/// What a signal source may read about a request. +/// +/// A struct rather than a parameter list, so a source needing something new +/// does not change every implementation. +pub struct SignalInput<'a> { + /// The decoded consent record for this request. + pub consent: &'a ConsentContext, + /// The policy from `permissions.yaml`, which decides what a signal means + /// rather than leaving each source to invent its own meaning. + pub policy: &'a SignalPolicy, + /// What the country and region rules say about this permission, before any + /// source is asked. A source amends this rather than deciding alone. + pub baseline: Acquisition, + /// What the sources asked before this one settled on. + /// + /// [`ConsentSignal::Neutral`] means none of them had an opinion, so the + /// baseline still stands. + pub settled: ConsentSignal, + /// Every source in configured order, this one included. + sources: &'a [Arc], + /// Where in that order the source being asked sits. + position: usize, + /// Whether this source may consult a peer. + /// + /// False while answering a consultation, which is what stops two sources + /// that consult each other from looping. + may_ask: bool, +} + +impl<'a> SignalInput<'a> { + /// An input for a source asked on its own, outside an ordered run. + #[must_use] + pub fn new( + consent: &'a ConsentContext, + policy: &'a SignalPolicy, + baseline: Acquisition, + ) -> Self { + Self { + consent, + policy, + baseline, + settled: ConsentSignal::Neutral, + sources: &[], + position: 0, + may_ask: true, + } + } + + /// Every source in configured order, this one included. + /// + /// A source consults the list to decide whether a peer it cares about is + /// configured at all, and where it sits relative to this one. + #[must_use] + pub fn sources(&self) -> &[Arc] { + self.sources + } + + /// Where the source being asked sits in that order. + #[must_use] + pub const fn position(&self) -> usize { + self.position + } + + /// Whether a source with this identifier is configured. + #[must_use] + pub fn has(&self, id: &str) -> bool { + self.sources.iter().any(|source| source.id() == id) + } + + /// What a peer makes of `permission`, asked directly. + /// + /// The peer answers as if it were first, so the reply is that peer's own + /// opinion rather than what the run has settled on so far. That is the + /// useful question: a source wanting to know whether the prior value came + /// from a particular peer asks that peer what it says. + /// + /// Returns `None` when no source carries the identifier, when the caller + /// names itself, and when this input is itself answering a consultation. + /// A source must handle `None` rather than assume a peer is present. + #[must_use] + pub fn ask(&self, id: &str, permission: Permission) -> Option { + if !self.may_ask { + return None; + } + let (position, source) = self + .sources + .iter() + .enumerate() + .find(|(position, source)| source.id() == id && *position != self.position)?; + let input = Self { + consent: self.consent, + policy: self.policy, + baseline: self.baseline, + settled: ConsentSignal::Neutral, + sources: self.sources, + position, + may_ask: false, + }; + Some(source.signal(permission, &input)) + } +} + +/// A source of permission signals. +/// +/// An implementation answers for one signalling model. It reads the request, +/// applies whatever the policy says about its own model, and returns how it +/// would amend one permission. +/// +/// # Contract +/// +/// Answer [`ConsentSignal::Neutral`] for a permission this source has no +/// opinion on, including when the signal it reads is absent from the request. +/// Returning [`ConsentSignal::Revoke`] for an absent signal would turn silence +/// into refusal and revoke the permission on every request that did not carry +/// this model. +pub trait PermissionSignalSource: Send + Sync { + /// Stable identifier, used in configuration, in logs, and by a peer + /// looking this source up through [`SignalInput::ask`]. + fn id(&self) -> &'static str; + + /// How this source would amend `permission` for this request. + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; +} + +/// Asks every source in order and returns what they settle on together. +/// +/// See the module documentation for the layering and why the order is the +/// configuration. +#[must_use] +pub fn combine( + sources: &[Arc], + permission: Permission, + consent: &ConsentContext, + policy: &SignalPolicy, + baseline: Acquisition, +) -> ConsentSignal { + let mut settled = ConsentSignal::Neutral; + for (position, source) in sources.iter().enumerate() { + let input = SignalInput { + consent, + policy, + baseline, + settled, + sources, + position, + may_ask: true, + }; + // Every source is asked, because a later one may amend what an earlier + // one settled. Stopping at the first answer would make the order mean + // the opposite of what it says. + match source.signal(permission, &input) { + ConsentSignal::Neutral => {} + answer => settled = answer, + } + } + settled +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A source that always answers the same thing, for testing the rule + /// rather than any particular model. + struct Fixed(&'static str, ConsentSignal); + + impl PermissionSignalSource for Fixed { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + self.1 + } + } + + /// A source that answers by consulting a peer, which is the behavior the + /// peer visibility exists for. + struct Consulting { + id: &'static str, + peer: &'static str, + } + + impl PermissionSignalSource for Consulting { + fn id(&self) -> &'static str { + self.id + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + match input.ask(self.peer, permission) { + // The peer refused, and this source takes the opposite view of + // the same request, which is the override the seam allows. + Some(ConsentSignal::Revoke) => ConsentSignal::Grant, + _ => ConsentSignal::Neutral, + } + } + } + + fn combined(sources: &[Arc]) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + combine( + sources, + Permission::StoreOnDevice, + &consent, + &policy, + Acquisition::RequiresSignal, + ) + } + + /// The same, for a run whose sources differ only in what they answer. + fn combined_signals(signals: &[ConsentSignal]) -> ConsentSignal { + let sources: Vec> = signals + .iter() + .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .collect(); + combined(&sources) + } + + #[test] + fn no_sources_leaves_the_place_baseline_alone() { + assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); + } + + #[test] + fn the_last_source_with_an_opinion_decides() { + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Grant]), + ConsentSignal::Grant, + "a visitor who answers a prompt after arriving with an opt-out header has \ + their answer applied over it, which is why the order is the policy" + ); + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Revoke]), + ConsentSignal::Revoke, + "and a deployment that wants the opt-out to win puts it last" + ); + } + + #[test] + fn silence_leaves_an_earlier_answer_standing() { + // The failure this guards is a later source overwriting a settled + // answer with its own absence, which would let adding a source nobody + // uses undo the one that was working. + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), + ConsentSignal::Grant + ); + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Neutral]), + ConsentSignal::Revoke + ); + } + + #[test] + fn silence_from_every_source_is_not_a_refusal() { + // The failure this guards is a source that reads an absent signal as a + // refusal. It would revoke the permission on every request that did not + // carry that model, which is most of them. + assert_eq!( + combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), + ConsentSignal::Neutral + ); + } + + #[test] + fn a_source_sees_what_the_earlier_ones_settled() { + struct Recording; + + impl PermissionSignalSource for Recording { + fn id(&self) -> &'static str { + "recording" + } + + fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert_eq!( + input.settled, + ConsentSignal::Revoke, + "a source is asked with the value the sources before it settled on" + ); + assert_eq!(input.position(), 1, "and with its own place in the order"); + assert_eq!(input.sources().len(), 2, "and with the whole list"); + assert_eq!( + input.baseline, + Acquisition::RequiresSignal, + "and with what the place rules said before anyone was asked" + ); + ConsentSignal::Neutral + } + } + + let sources: Vec> = vec![ + Arc::new(Fixed("opt-out", ConsentSignal::Revoke)), + Arc::new(Recording), + ]; + assert_eq!(combined(&sources), ConsentSignal::Revoke); + } + + #[test] + fn a_source_can_override_a_peer_by_consulting_it() { + // The worked example from the README: a later source overrides a + // refusal because of who made it, not merely that one was made. + let sources: Vec> = vec![ + Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Grant); + + // The same source leaves the refusal alone when it came from a peer it + // was not told to override. + let sources: Vec> = vec![ + Arc::new(Fixed("other", ConsentSignal::Revoke)), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Revoke); + } + + #[test] + fn asking_reaches_a_peer_wherever_it_sits_in_the_order() { + // A source may consult one configured after it, not only before, so a + // reordering does not silently change what a source can see. + let sources: Vec> = vec![ + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + ]; + assert_eq!( + combined(&sources), + ConsentSignal::Revoke, + "the consultation succeeded, and the later opt-out then settled it" + ); + } + + #[test] + fn asking_for_a_source_that_is_not_configured_answers_nothing() { + struct Absent; + + impl PermissionSignalSource for Absent { + fn id(&self) -> &'static str { + "absent" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert!( + input.ask("not-configured", permission).is_none(), + "a source must be able to tell a missing peer from a silent one" + ); + assert!(!input.has("not-configured")); + assert!(input.has("absent"), "and can see itself in the list"); + ConsentSignal::Neutral + } + } + + let sources: Vec> = vec![Arc::new(Absent)]; + assert_eq!(combined(&sources), ConsentSignal::Neutral); + } + + #[test] + fn a_source_cannot_consult_itself() { + struct SelfAsking; + + impl PermissionSignalSource for SelfAsking { + fn id(&self) -> &'static str { + "self-asking" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // Without the guard this recurses until the stack is gone. + assert!(input.ask("self-asking", permission).is_none()); + ConsentSignal::Grant + } + } + + let sources: Vec> = vec![Arc::new(SelfAsking)]; + assert_eq!(combined(&sources), ConsentSignal::Grant); + } + + #[test] + fn two_sources_that_consult_each_other_do_not_loop() { + // Each consults the other, and the one answering a consultation is + // refused a consultation of its own, so the pair settles instead of + // recursing. + let sources: Vec> = vec![ + Arc::new(Consulting { + id: "first", + peer: "second", + }), + Arc::new(Consulting { + id: "second", + peer: "first", + }), + ]; + assert_eq!(combined(&sources), ConsentSignal::Neutral); + } +} diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1d441530c1..1907302cee 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -710,7 +710,7 @@ impl PermissionMaps { &self, country: Option<&str>, region: Option<&str>, - signal: impl Fn(Permission) -> ConsentSignal, + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, ) -> PermissionState { Self::resolve_rules(self.rules_or_default(country, region), signal) } @@ -723,7 +723,9 @@ impl PermissionMaps { /// must not paper over, so nothing is set unless the session's signals /// grant it. #[must_use] - pub fn floor_with(signal: impl Fn(Permission) -> ConsentSignal) -> PermissionState { + pub fn floor_with( + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, + ) -> PermissionState { Self::resolve_rules(None, signal) } @@ -731,20 +733,24 @@ impl PermissionMaps { /// when no rules resolved. fn resolve_rules( rules: Option<&CountryRules>, - signal: impl Fn(Permission) -> ConsentSignal, + signal: impl Fn(Permission, Acquisition) -> ConsentSignal, ) -> PermissionState { let acquisition = |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); let set = Permission::all() - .filter( - |&permission| match (acquisition(permission), signal(permission)) { + .filter(|&permission| { + // The baseline is passed to the signal as well as applied to + // its answer, because a source amends the place rules and + // cannot amend what it cannot see. + let baseline = acquisition(permission); + match (baseline, signal(permission, baseline)) { (Acquisition::Denied, _) => false, (Acquisition::Granted, ConsentSignal::Revoke) => false, (Acquisition::Granted, _) => true, (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, (Acquisition::RequiresSignal, _) => false, - }, - ) + } + }) .collect(); PermissionState { set } } @@ -757,7 +763,7 @@ impl PermissionMaps { /// and is what a request resolves to when no signal is present. #[must_use] pub fn baseline(&self, country: Option<&str>, region: Option<&str>) -> PermissionState { - self.resolve_with(country, region, |_| ConsentSignal::Neutral) + self.resolve_with(country, region, |_, _| ConsentSignal::Neutral) } /// Convenience over [`resolve_with`](Self::resolve_with) for a boolean @@ -769,7 +775,7 @@ impl PermissionMaps { country: Option<&str>, signal: impl Fn(Permission) -> bool, ) -> PermissionState { - self.resolve_with(country, None, |permission| { + self.resolve_with(country, None, |permission, _| { if signal(permission) { ConsentSignal::Grant } else { @@ -1434,7 +1440,7 @@ mod tests { "the top node grants storage, or this test proves nothing" ); assert!( - !PermissionMaps::floor_with(|_| ConsentSignal::Neutral) + !PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral) .is_set(Permission::StoreOnDevice), "the floor must not fall back to the top node" ); @@ -1626,7 +1632,7 @@ mod tests { .is_set(Permission::StoreOnDevice), "the US baseline should set necessary.operations.storage" ); - let revoked = maps.resolve_with(Some("US"), None, |p| { + let revoked = maps.resolve_with(Some("US"), None, |p, _| { if p == Permission::StoreOnDevice { ConsentSignal::Revoke } else { @@ -1708,7 +1714,7 @@ rules: // so it is not set even when a signal grants it. assert!( !maps - .resolve_with(Some("US"), Some("CA"), |_| ConsentSignal::Grant) + .resolve_with(Some("US"), Some("CA"), |_, _| ConsentSignal::Grant) .is_set(Permission::SelectBasicAds), "the permissions map denies advertising_marketing.first_party.contextual even when a signal grants it" ); From 5383a365108096d5183efd524fdc7baef4ecd045 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 15:40:54 +0100 Subject: [PATCH 35/82] Name the permission signal models in configuration The ordered list was core's own and an operator could not change it, so the order was real but nobody outside core could set it. A new [permission_signal] section names the models to run, in order. A model that is not on the list does not run, and there is no separate switch to turn one off, so a publisher who does not want to act on Global Privacy Control removes "gpc" from the list. Leaving the section out runs every model the build knows about, in the default order, so a signal is never quietly ignored because someone forgot to list it. An empty list runs none, leaving every permission at its country and region baseline. Split the single us-opt-out source into gpc, gpp-sale-opt-out and us-privacy. One source covering all three could not be pruned: removing Global Privacy Control would have taken the GPP and US Privacy opt-outs with it. Each still asks the policy whether its signal counts and what it takes away, so permissions.yaml keeps the meaning and the list keeps the membership. No behavior changes by default, because each returns Revoke or Neutral and never Grant, so three in sequence settle where one did. An unknown or repeated name is refused at startup by validate_selection, rather than silently matching nothing and stopping a model being honored. SOURCE_IDS is what configuration is checked against, and a test asserts it matches what all_sources actually returns, since the two drifting apart would let a name validate and then resolve to nothing. assemble_permissions keeps its signature and runs every model, which is the default. assemble_permissions_with takes the configured list, and the request path passes what the operator set. trusted-server.example.toml ships the full list commented, with a note to remove what the publisher does not want and a caution that dropping malformed-record means an unreadable record falls back to the baseline instead of failing closed. cargo fmt clean, clippy clean on all five targets, 2423 core tests, 2613 fastly, 40 axum, 43 cloudflare, 83 spin. --- crates/trusted-server-core/src/ec/consent.rs | 275 ++++++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 6 +- .../src/permission_signal/README.md | 15 +- crates/trusted-server-core/src/settings.rs | 145 +++++++++ trusted-server.example.toml | 37 +++ 5 files changed, 452 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 2ab0b747e4..059b793572 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -87,8 +87,23 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { /// protectively rather than as the policy's declared default. #[must_use] pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { + assemble_permissions_with(consent, geo, &all_sources()) +} + +/// As [`assemble_permissions`], for a deployment that has named which signal +/// models run and in what order. +/// +/// A model missing from `sources` does not run, so a publisher removes one by +/// leaving it out rather than by configuring it off. An empty slice runs none +/// of them, which leaves every permission at its country and region baseline. +#[must_use] +pub fn assemble_permissions_with( + consent: &ConsentContext, + geo: GeoStatus<'_>, + sources: &[Arc], +) -> PermissionState { let maps = PermissionMaps::standard(); - let signal = permission_signal(consent, maps.signals()); + let signal = permission_signal(consent, maps.signals(), sources); match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { @@ -149,14 +164,30 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { fn permission_signal<'a>( consent: &'a ConsentContext, signals: &'a SignalPolicy, + sources: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { - let sources = builtin_sources(); move |permission, baseline| { - crate::permission_signal::combine(&sources, permission, consent, signals, baseline) + crate::permission_signal::combine(sources, permission, consent, signals, baseline) } } -/// The signal models core supplies itself. +/// The identifiers of every signal model core supplies, in the default order. +/// +/// Configuration names sources from this list, and +/// [`PermissionSignalConfig::validate_selection`] rejects a name that is not in +/// it at startup rather than silently ignoring it. +/// +/// [`PermissionSignalConfig::validate_selection`]: +/// crate::settings::PermissionSignalConfig::validate_selection +pub const SOURCE_IDS: &[&str] = &[ + "gpc", + "gpp-sale-opt-out", + "us-privacy", + "malformed-record", + "tcf", +]; + +/// Every signal model core supplies, in the default order. /// /// These were decoded inline until the seam existed. They are the same rules, /// moved behind [`PermissionSignalSource`] so a further model can be added by a @@ -164,37 +195,110 @@ fn permission_signal<'a>( /// /// The order runs the signals needing no interaction before the ones following /// a prompt, so a visitor who arrives with an opt-out and then answers a prompt -/// has their answer applied. A deployment wanting the opposite puts the opt-out -/// source last. +/// has their answer applied. A deployment wanting the opposite reorders the +/// list in configuration. /// /// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource -fn builtin_sources() -> Vec> { +#[must_use] +pub fn all_sources() -> Vec> { vec![ - Arc::new(UsOptOutSource), + Arc::new(GpcSource), + Arc::new(GppSaleOptOutSource), + Arc::new(UsPrivacySource), Arc::new(MalformedRecordSource), Arc::new(TcfSource), ] } -/// A US-style opt-out, from Global Privacy Control, a GPP sale opt-out, or a -/// US Privacy string. +/// The sources a deployment named, in the order it named them. +/// +/// `None` means nothing was configured, which runs all of them in the default +/// order. That is deliberate: a publisher gets every model the build knows +/// about until they say otherwise, so a signal is never quietly ignored because +/// someone forgot to list it. +/// +/// A name that matches nothing is dropped here, having already been rejected at +/// startup by [`PermissionSignalConfig::validate_selection`]. +/// +/// [`PermissionSignalConfig::validate_selection`]: +/// crate::settings::PermissionSignalConfig::validate_selection +#[must_use] +pub fn sources_for(configured: Option<&[String]>) -> Vec> { + let all = all_sources(); + let Some(names) = configured else { + return all; + }; + names + .iter() + .filter_map(|name| { + all.iter() + .find(|source| source.id() == name.as_str()) + .map(Arc::clone) + }) + .collect() +} + +/// Whether one US-style opt-out takes `permission` away on this request. /// -/// Which of those count, and what an opt-out takes away, are both the policy's -/// decisions rather than this source's. It only reads whether one is present. -struct UsOptOutSource; +/// Shared by the three sources below, which differ only in the signal they +/// read. They are separate sources rather than one so that a publisher who does +/// not want to act on Global Privacy Control can leave that source out of the +/// configured list without also losing the GPP and US Privacy opt-outs. +/// +/// Which signals count at all, and what an opt-out takes away, remain the +/// policy's decisions. A source that the policy does not list stays silent even +/// when configuration names it, so removing it from `opt_out_sources` in +/// `permissions.yaml` and leaving it out of the list have the same effect. +fn opt_out_signal( + source: OptOutSource, + permission: Permission, + input: &SignalInput<'_>, +) -> ConsentSignal { + if input.policy.opt_out_sources().contains(&source) + && opt_out_present(input.consent, &[source]) + && input.policy.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + ConsentSignal::Neutral +} -impl PermissionSignalSource for UsOptOutSource { +/// The `Sec-GPC` request header, Global Privacy Control. +struct GpcSource; + +impl PermissionSignalSource for GpcSource { fn id(&self) -> &'static str { - "us-opt-out" + "gpc" } fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if opt_out_present(input.consent, input.policy.opt_out_sources()) - && input.policy.opt_out_revokes(permission) - { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral + opt_out_signal(OptOutSource::Gpc, permission, input) + } +} + +/// A GPP US sale opt-out. +struct GppSaleOptOutSource; + +impl PermissionSignalSource for GppSaleOptOutSource { + fn id(&self) -> &'static str { + "gpp-sale-opt-out" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + opt_out_signal(OptOutSource::GppSaleOptOut, permission, input) + } +} + +/// A US Privacy string sale opt-out. +struct UsPrivacySource; + +impl PermissionSignalSource for UsPrivacySource { + fn id(&self) -> &'static str { + "us-privacy" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + opt_out_signal(OptOutSource::UsPrivacyOptOut, permission, input) } } @@ -386,6 +490,135 @@ mod tests { ); } + // ------------------------------------------------------------------ + // Which models run. A publisher names them in [permission_signal] + // sources, and one left off the list does not run at all. + // ------------------------------------------------------------------ + + /// Every identifier except the one named, in the declared order. + fn every_source_except(excluded: &str) -> Vec { + SOURCE_IDS + .iter() + .filter(|id| **id != excluded) + .map(|id| (*id).to_owned()) + .collect() + } + + #[test] + fn the_declared_identifiers_match_the_models_that_run() { + // Configuration is validated against SOURCE_IDS and resolved against + // all_sources, so the two drifting apart would let a name validate and + // then match nothing, silently dropping a model. + let running: Vec<&str> = all_sources().iter().map(|source| source.id()).collect(); + assert_eq!( + running, SOURCE_IDS, + "SOURCE_IDS is what configuration is checked against, so it has to be what runs" + ); + } + + #[test] + fn naming_nothing_runs_every_model() { + let ids: Vec<&str> = sources_for(None).iter().map(|source| source.id()).collect(); + assert_eq!( + ids, SOURCE_IDS, + "a publisher who configures nothing acts on every signal the build knows, so \ + one is never ignored because they forgot to list it" + ); + } + + #[test] + fn the_configured_order_is_the_order_they_are_asked_in() { + let reversed: Vec = SOURCE_IDS.iter().rev().map(|id| (*id).to_owned()).collect(); + let ids: Vec = sources_for(Some(&reversed)) + .iter() + .map(|source| source.id().to_owned()) + .collect(); + assert_eq!( + ids, reversed, + "the list is the order, not merely the membership" + ); + } + + #[test] + fn a_model_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = + assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every model running, the header takes storage away" + ); + + let without_gpc = every_source_except("gpc"); + let pruned = assemble_permissions_with( + &consent, + GeoStatus::Located(&geo), + &sources_for(Some(&without_gpc)), + ); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the header then changes nothing" + ); + } + + #[test] + fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate sources rather than one. + let consent = ConsentContext { + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let without_gpc = every_source_except("gpc"); + let state = assemble_permissions_with( + &consent, + GeoStatus::Located(&geo), + &sources_for(Some(&without_gpc)), + ); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); + } + + #[test] + fn running_no_models_leaves_the_place_baseline() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + assert!( + state.is_set(Permission::StoreOnDevice), + "an empty list is a publisher acting on no signal at all, so only the country \ + and region rules apply" + ); + } + + #[test] + fn an_unknown_name_resolves_to_nothing_rather_than_a_wrong_model() { + // Startup validation rejects this first. The check here is that if one + // ever reached this far it would drop out rather than match by position. + let named = vec!["not-a-source".to_owned(), "tcf".to_owned()]; + let ids: Vec<&str> = sources_for(Some(&named)) + .iter() + .map(|source| source.id()) + .collect(); + assert_eq!(ids, vec!["tcf"]); + } + // ------------------------------------------------------------------ // Opt-out and prompt precedence. // diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 7bc4cdc309..91a5210a2e 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -351,7 +351,11 @@ impl EcContext { // signals. Downstream consumers read the stored result via // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than // re-deriving it. - let permissions = consent::assemble_permissions(&consent, geo_status); + let permissions = consent::assemble_permissions_with( + &consent, + geo_status, + &consent::sources_for(settings.permission_signal.sources.as_deref()), + ); let storage_acquisition = consent::storage_acquisition(geo_status); // With no provider selected nothing may create or use an identifier, so // the gate is closed rather than open by default. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index ddffce6f37..78c4d4d304 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -43,10 +43,17 @@ prior value standing, which is different from refusing. The last source with an opinion decides, so the order is the policy. It is a deployment's to set, not this code's to assume. -Today the order is the order of the list handed to `combine`, which core -assembles itself. Naming the sources in operator configuration arrives with -the split into vendor crates, because a name in a configuration file is only -useful once a source can come from outside core. +```toml +[permission_signal] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "malformed-record", "tcf"] +``` + +A source not on the list does not run, and there is no separate switch. A +publisher who does not want to act on Global Privacy Control removes `"gpc"` +from the list. Leaving the section out entirely runs every model the build +knows about, in the default order, so a signal is never quietly ignored +because someone forgot to list it. An unknown or repeated name is refused at +startup, so a typo cannot silently stop a model being honored. The default order asks the signals needing no interaction first and the ones following a prompt after. Global Privacy Control withdraws personalisation on diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c33450da32..d8f1aa5111 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -891,6 +891,68 @@ impl DeviceConfig { } } +/// Which permission signal models run, and in what order. +/// +/// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, +/// `[geo]` and `[device]` selectors, which each name one provider, signals +/// compose: a request can carry a TCF string and a Global Privacy Control +/// header at once and both have something to say. So this names a list, and +/// the order is the policy, because the last source with an opinion decides. +/// +/// See `crates/trusted-server-core/src/permission_signal/README.md`. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PermissionSignalConfig { + /// The models to run, in order, named by the identifiers in + /// [`SOURCE_IDS`](crate::ec::consent::SOURCE_IDS). + /// + /// Absent means every model the build knows about, in the default order. + /// A publisher who does not want to act on one removes it from the list; + /// there is no separate switch, because a model that is not listed does not + /// run. An empty list runs none of them, leaving every permission at its + /// country and region baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, +} + +impl PermissionSignalConfig { + /// Checks that every named model exists in this build and none is named + /// twice. + /// + /// Run at startup, so a typo is a refusal to boot rather than a signal that + /// silently stops being honored. + /// + /// # Errors + /// + /// - [`TrustedServerError::Configuration`] if a name is unknown or repeated. + pub fn validate_selection(&self) -> Result<(), Report> { + let Some(names) = self.sources.as_deref() else { + return Ok(()); + }; + for (position, name) in names.iter().enumerate() { + if !crate::ec::consent::SOURCE_IDS.contains(&name.as_str()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal source `{name}` is not available in this build. \ + Available sources are {}", + crate::ec::consent::SOURCE_IDS.join(", ") + ), + })); + } + if names[..position].contains(name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal source `{name}` is named more than once in \ + [permission_signal] sources. Each source runs once, at one place \ + in the order" + ), + })); + } + } + Ok(()) + } +} + /// Geo / IP intelligence configuration. /// /// Mapped from the `[geo]` TOML section. Selects which provider resolves a @@ -2993,6 +3055,10 @@ fn is_default_geo_config(value: &GeoConfig) -> bool { *value == GeoConfig::default() } +fn is_default_permission_signal_config(value: &PermissionSignalConfig) -> bool { + *value == PermissionSignalConfig::default() +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -3289,6 +3355,9 @@ pub struct Settings { #[serde(default, skip_serializing_if = "is_default_geo_config")] #[validate(nested)] pub geo: GeoConfig, + #[serde(default, skip_serializing_if = "is_default_permission_signal_config")] + #[validate(nested)] + pub permission_signal: PermissionSignalConfig, } impl Settings { @@ -3380,6 +3449,7 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; + settings.permission_signal.validate_selection()?; GeoConfig::validate_permission_policy()?; settings .geo @@ -8629,3 +8699,78 @@ formats = [{{ width = 300, height = 250 }}] } } } + +#[cfg(test)] +mod permission_signal_config_tests { + use super::*; + + fn config(sources: Option<&[&str]>) -> PermissionSignalConfig { + PermissionSignalConfig { + sources: sources.map(|names| names.iter().map(|name| (*name).to_owned()).collect()), + } + } + + #[test] + fn no_section_is_allowed_and_means_every_model() { + let config = PermissionSignalConfig::default(); + config + .validate_selection() + .expect("should accept a deployment that configures nothing"); + assert!( + config.sources.is_none(), + "absent rather than empty, because the two mean opposite things" + ); + } + + #[test] + fn every_declared_source_is_accepted() { + config(Some(crate::ec::consent::SOURCE_IDS)) + .validate_selection() + .expect("should accept the full list the example configuration ships"); + } + + #[test] + fn an_empty_list_is_accepted_as_acting_on_no_signal() { + config(Some(&[])) + .validate_selection() + .expect("should accept a publisher who acts on no signal at all"); + } + + #[test] + fn an_unknown_source_is_refused_at_startup() { + let error = config(Some(&["gpc", "gpq"])) + .validate_selection() + .expect_err("should refuse a name no model answers to"); + let message = format!("{error:?}"); + assert!( + message.contains("gpq"), + "the message should name the typo, so it can be found: {message}" + ); + assert!( + message.contains("gpc"), + "and list what was available: {message}" + ); + } + + #[test] + fn naming_a_source_twice_is_refused() { + let error = config(Some(&["gpc", "tcf", "gpc"])) + .validate_selection() + .expect_err("should refuse a repeat, which has no meaning in an ordered list"); + assert!(format!("{error:?}").contains("gpc")); + } + + #[test] + fn the_section_round_trips_through_toml() { + let parsed: PermissionSignalConfig = + toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); + assert_eq!( + parsed.sources.as_deref(), + Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), + "the order written is the order read, because the order is the policy" + ); + parsed + .validate_selection() + .expect("should accept two known sources"); + } +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index cdf817ecd9..e52a243113 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -203,6 +203,43 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 +# Which permission signal models run, and in what order. +# +# Signals compose rather than select. A request can carry a TCF string and a +# Global Privacy Control header at once, and both have something to say, so +# this is a list where [ec], [geo] and [device] each name one provider. The +# order is the policy, because the last source with an opinion decides. +# +# Listed below is every model this build knows about, which is also exactly +# what runs when this section is absent. Remove the ones this publisher does +# not want to act on. A model that is not on the list does not run, and there +# is no separate switch to turn one off. An unknown or repeated name is +# refused at startup rather than quietly ignored. +# +# [permission_signal] +# sources = [ +# "gpc", # the Sec-GPC request header, Global Privacy Control +# "gpp-sale-opt-out", # a GPP US sale opt-out +# "us-privacy", # a US Privacy string sale opt-out +# "malformed-record", # a consent record that arrived and could not be read +# "tcf", # TCF v2 +# ] +# +# The three opt-outs are separate entries so that a publisher who does not act +# on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy +# opt-outs working. +# +# Removing "malformed-record" means a consent record that arrives unreadable +# falls back to the country and region baseline instead of failing closed. +# That is a real choice, so make it deliberately. +# +# The default order asks the signals needing no interaction before the ones +# that follow a prompt, so a visitor who arrives with an opt-out and then +# answers a prompt has their answer applied. Reorder the list to change that. +# +# The layering and what a model may consult are documented in +# crates/trusted-server-core/src/permission_signal/README.md. + # Proxy behavior and first-party asset routing. Kept active with defaults. [proxy] # Verify TLS certs when proxying to HTTPS origins (default true; false only for From 1c369bf7abb4797f8ea2b0ae88afeabe968a4e8f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 7 Sep 2026 17:17:51 +0100 Subject: [PATCH 36/82] Stop treating an unreadable consent record as a model to choose malformed-record was never a signalling model. It is the flag ConsentContext::has_malformed_record sets when a raw TCF, GPP or US Privacy string arrives and will not decode, and the original code checked it as an early return ahead of TCF so an unreadable preference failed closed. Moving the inline chain behind the source trait made it look like a peer of gpc and tcf, and the configuration work then offered it in the list, so a publisher could remove it and silently turn off fail-closed handling that was never optional. It also read as incoherent next to the others: a publisher who removes tcf is saying which signal they act on, while removing malformed-record would be saying what happens when a signal they do act on arrives broken. Those are different questions. It is now applied ahead of the configured models and regardless of which are configured, and it is out of SOURCE_IDS entirely. That also fixes a regression the ordered rule introduced. With it sitting in the order, a TCF record that consents was asked after the unreadable GPP string and overwrote the refusal it caused, which the original early return never allowed. It overrides rather than holding a position, so one model arriving unreadable is not cured by another arriving readable. Two tests cover it, including that case. Startup still logs the selection, and now warns about every model left out rather than one, since a signal read from a request and then ignored is worth seeing when someone asks why it had no effect. cargo fmt clean, clippy clean on all five targets, 2429 core tests, 2619 fastly, 40 axum. --- crates/trusted-server-core/src/ec/consent.rs | 88 +++++++++++------ .../src/permission_signal/README.md | 26 ++++- crates/trusted-server-core/src/settings.rs | 95 +++++++++++++++++++ trusted-server.example.toml | 7 +- 4 files changed, 179 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 059b793572..7b1447d3b4 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -167,6 +167,22 @@ fn permission_signal<'a>( sources: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { + // A record that arrived and could not be read fails closed, ahead of + // every configured model and regardless of which are configured. + // + // This is not a signalling model and is deliberately not in the + // configured list. A publisher chooses which signals to act on; they do + // not choose what happens when one of those signals arrives unreadable. + // An unreadable record is a preference someone expressed that cannot be + // read, which is different from no record at all, so it must not + // degrade to the no-signal baseline. + // + // It overrides rather than taking a place in the order because the + // ordered rule would otherwise let a readable record from one model + // overwrite the refusal caused by an unreadable one from another. + if consent.has_malformed_record() { + return ConsentSignal::Revoke; + } crate::permission_signal::combine(sources, permission, consent, signals, baseline) } } @@ -179,13 +195,7 @@ fn permission_signal<'a>( /// /// [`PermissionSignalConfig::validate_selection`]: /// crate::settings::PermissionSignalConfig::validate_selection -pub const SOURCE_IDS: &[&str] = &[ - "gpc", - "gpp-sale-opt-out", - "us-privacy", - "malformed-record", - "tcf", -]; +pub const SOURCE_IDS: &[&str] = &["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"]; /// Every signal model core supplies, in the default order. /// @@ -205,7 +215,6 @@ pub fn all_sources() -> Vec> { Arc::new(GpcSource), Arc::new(GppSaleOptOutSource), Arc::new(UsPrivacySource), - Arc::new(MalformedRecordSource), Arc::new(TcfSource), ] } @@ -302,26 +311,6 @@ impl PermissionSignalSource for UsPrivacySource { } } -/// A consent record that arrived and could not be read. -/// -/// Distinct from no record at all. An absent record is silence and leaves the -/// place baseline standing. A record that is present and malformed is a signal -/// we cannot trust, so it revokes rather than being ignored. -struct MalformedRecordSource; - -impl PermissionSignalSource for MalformedRecordSource { - fn id(&self) -> &'static str { - "malformed-record" - } - - fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if input.consent.has_malformed_record() { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral - } -} - /// TCF v2, when the policy says TCF answers for this deployment. /// /// The mapping from permission to purpose is the policy's, so this source @@ -490,6 +479,49 @@ mod tests { ); } + // ------------------------------------------------------------------ + // An unreadable record. Not a model a publisher lists, so it applies + // whatever they configured, and it is not subject to the ordering. + // ------------------------------------------------------------------ + + #[test] + fn an_unreadable_record_revokes_even_with_no_models_configured() { + let consent = ConsentContext { + raw_tc_string: Some("this is not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert!( + consent.has_malformed_record(), + "the fixture has to actually be unreadable for this to test anything" + ); + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a preference someone expressed that cannot be read must not degrade to the \ + no-signal baseline, and a publisher cannot configure that away" + ); + } + + #[test] + fn a_readable_record_does_not_overwrite_an_unreadable_one() { + // The regression the override exists to prevent: under the ordered + // rule alone, a TCF record that consents would be asked after the + // unreadable GPP string and would overwrite the refusal it caused. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert!(consent.has_malformed_record()); + let geo = us_ca_geo(); + let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "one model arriving unreadable is not cured by another model arriving readable" + ); + } + // ------------------------------------------------------------------ // Which models run. A publisher names them in [permission_signal] // sources, and one left off the list does not run at all. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 78c4d4d304..4dd0fdfdf5 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -45,7 +45,7 @@ deployment's to set, not this code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "malformed-record", "tcf"] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] ``` A source not on the list does not run, and there is no separate switch. A @@ -92,7 +92,23 @@ without changing a source. ## The models supplied -Core supplies the models it already understood: a US-style opt-out (Global -Privacy Control, a GPP sale opt-out, or a US Privacy string), a consent record -that arrived unreadable, and TCF v2. A deployment configuring nothing gets -those, in that order. +Core supplies the models it already understood: Global Privacy Control, a GPP +sale opt-out, a US Privacy string, and TCF v2. A deployment configuring +nothing gets those, in that order. + +The three opt-outs are separate rather than one so that a publisher who does +not act on Global Privacy Control can remove it and keep the other two. + +## What is not a source + +A consent record that arrives and cannot be read revokes, ahead of every +configured model and whichever ones are configured. That is error handling, +not a signalling model, so it is not in the list and cannot be removed. A +publisher chooses which signals to act on; they do not choose what happens +when one of those signals arrives unreadable. An unreadable record is a +preference someone expressed that could not be read, which is not the same as +no record at all, so it must not degrade to the no-signal baseline. + +It overrides rather than taking a place in the order, because the ordered rule +would otherwise let a readable record from one model overwrite the refusal +caused by an unreadable one from another. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index d8f1aa5111..520225b3b8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -916,6 +916,60 @@ pub struct PermissionSignalConfig { } impl PermissionSignalConfig { + /// The models this configuration leaves out, in the declared order. + /// + /// Empty when nothing is configured, since that runs every model. + #[must_use] + pub fn omitted_sources(&self) -> Vec<&'static str> { + let Some(names) = self.sources.as_deref() else { + return Vec::new(); + }; + crate::ec::consent::SOURCE_IDS + .iter() + .copied() + .filter(|id| !names.iter().any(|name| name.as_str() == *id)) + .collect() + } + + /// Records the selection at startup, so which signals a deployment acts on + /// can be read from its log rather than inferred from its behavior. + /// + /// Any model left out is warned about, not merely noted. Removing one is a + /// deliberate choice a publisher is entitled to make, so it is not a + /// refusal, but a signal arriving on a request and then being ignored is + /// worth seeing in a log when someone asks why it had no effect. + pub fn log_selection(&self) { + let Some(names) = self.sources.as_deref() else { + log::info!( + "Permission signals: acting on every model, no [permission_signal] sources \ + configured" + ); + return; + }; + + if names.is_empty() { + log::info!( + "Permission signals: acting on no model, [permission_signal] sources is \ + empty, so every permission stays at its country and region baseline" + ); + } else { + log::info!( + "Permission signals: acting on {}, asked in that order", + names.join(", ") + ); + } + + let omitted = self.omitted_sources(); + if !omitted.is_empty() { + log::warn!( + "Permission signals: not acting on {}, which are not in [permission_signal] \ + sources. A signal this deployment does not act on is read from the request \ + and then ignored", + omitted.join(", ") + ); + } + } + /// Checks that every named model exists in this build and none is named /// twice. /// @@ -3450,6 +3504,7 @@ impl Settings { settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; settings.permission_signal.validate_selection()?; + settings.permission_signal.log_selection(); GeoConfig::validate_permission_policy()?; settings .geo @@ -8760,6 +8815,46 @@ mod permission_signal_config_tests { assert!(format!("{error:?}").contains("gpc")); } + #[test] + fn only_models_a_publisher_chooses_to_act_on_are_listed() { + // A malformed consent record fails closed whatever is configured, so it + // is deliberately not among the names. It is what happens when a signal + // arrives unreadable, not a signal anyone elects to honor. + assert!( + !crate::ec::consent::SOURCE_IDS.contains(&"malformed-record"), + "error handling must not be listed as though it were a signalling model" + ); + } + + #[test] + fn configuring_nothing_omits_nothing() { + assert!( + PermissionSignalConfig::default() + .omitted_sources() + .is_empty(), + "no section runs every model, so nothing is left out to report" + ); + } + + #[test] + fn a_dropped_model_is_reported_as_omitted() { + let omitted = config(Some(&["gpc", "tcf"])).omitted_sources(); + assert_eq!( + omitted, + vec!["gpp-sale-opt-out", "us-privacy"], + "what the log names has to be what was actually left out" + ); + } + + #[test] + fn an_empty_list_omits_every_model() { + assert_eq!( + config(Some(&[])).omitted_sources().len(), + crate::ec::consent::SOURCE_IDS.len(), + "acting on no signal leaves every model out, and the log says so" + ); + } + #[test] fn the_section_round_trips_through_toml() { let parsed: PermissionSignalConfig = diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e52a243113..e50e94b400 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -221,7 +221,6 @@ pull_sync_concurrency = 3 # "gpc", # the Sec-GPC request header, Global Privacy Control # "gpp-sale-opt-out", # a GPP US sale opt-out # "us-privacy", # a US Privacy string sale opt-out -# "malformed-record", # a consent record that arrived and could not be read # "tcf", # TCF v2 # ] # @@ -229,9 +228,9 @@ pull_sync_concurrency = 3 # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. # -# Removing "malformed-record" means a consent record that arrives unreadable -# falls back to the country and region baseline instead of failing closed. -# That is a real choice, so make it deliberately. +# A consent record that arrives and cannot be read revokes, whichever models +# are configured. That is error handling rather than a model to choose, so it +# is not in this list and cannot be removed. # # The default order asks the signals needing no interaction before the ones # that follow a prompt, so a visitor who arrives with an opt-out and then From 5d11c514cb34b479d1a11b1accb017482c7e4c6e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:14 +0100 Subject: [PATCH 37/82] Open the permission signal seam to providers outside core Core now supplies no signal model of its own. The seam that decides whether a permission is set takes providers from outside core, asks them in the order configuration gives, and applies the country and region rules to what they settle on. The four models that lived inline in ec/consent.rs are gone from core, and the trait they implemented is renamed PermissionSignalProvider and given what a provider for a scheme core has never heard of needs: the request as evidence, through RequestInfo, with a defaulted cookie accessor, alongside the decoded consent record for the schemes core's pipeline already decodes and caches. Three things move with the models. The TCF purpose to Data Use table leaves permissions.yaml and SignalPolicy, because which purpose grants which Data Use is the TCF scheme's own meaning rather than a deployment's policy, and a deployment that runs no TCF should carry no table of another scheme's numbers. Storage withdrawal becomes a provider's answer through a defaulted withdraws hook, scoped by core to the jurisdiction's storage baseline exactly as ec_storage_withdrawn scoped it, and is recorded on PermissionState so the finalize path reads it there. The hardcoded list of model identifiers leaves settings validation, because which names are valid is only known where the provider crates are linked, so an adapter's composition root now selects the providers once at startup through build_permission_signal_providers, which refuses an unknown or repeated name with a message naming what is available, and carries the shared list on RuntimeServices. One behavior of PR3 as pushed is not preserved, and it changed in the four commits this builds on rather than here. A US-style opt-out used to win over a consenting TCF record as a fixed rule in code. The providers are now asked in order and the last with an opinion decides. The default order asks the one signal with no interface of its own first, Global Privacy Control, and the three carrying a choice someone made through an interface after, so an answer given at a prompt amends the header the visitor arrived with, and a deployment wanting the opposite reorders the list. Every document that still stated the old rule is corrected here. --- config/permissions/vanilla.yaml | 80 +- crates/trusted-server-core/src/consent/mod.rs | 8 +- crates/trusted-server-core/src/ec/consent.rs | 834 ++++-------------- crates/trusted-server-core/src/ec/finalize.rs | 21 +- crates/trusted-server-core/src/ec/mod.rs | 56 +- crates/trusted-server-core/src/evidence.rs | 76 ++ .../src/permission_signal/README.md | 177 ++-- .../src/permission_signal/mod.rs | 571 +++++++++--- crates/trusted-server-core/src/permissions.rs | 153 ++-- .../trusted-server-core/src/platform/types.rs | 36 + crates/trusted-server-core/src/settings.rs | 231 +---- 11 files changed, 1047 insertions(+), 1196 deletions(-) diff --git a/config/permissions/vanilla.yaml b/config/permissions/vanilla.yaml index 2c8fc2fcc2..dfb0452bd8 100644 --- a/config/permissions/vanilla.yaml +++ b/config/permissions/vanilla.yaml @@ -497,59 +497,43 @@ rules: group: us-opt-out jurisdiction: us-state -# How each session signal maps onto Data Uses. The permission engine reads this, -# so no signal-to-permission policy lives in the code. For each Data Use a signal -# produces a grant or a revoke, which the resolver then applies against the group -# baseline above (a grant sets a `requires_signal` Data Use, a revoke drops a -# `granted` one, and `denied` always wins so no signal can set it). +# What each session signal is allowed to do to the Data Uses, for the +# decisions that are a deployment's policy rather than a scheme's own meaning. +# For each Data Use a signal produces a grant or a revoke, which the resolver +# then applies against the group baseline above (a grant sets a +# `requires_signal` Data Use, a revoke drops a `granted` one, and `denied` +# always wins so no signal can set it). +# +# What a scheme's own signal means is not here. Which TCF purpose grants which +# Data Use is the TCF scheme's knowledge and lives in the TCF permission signal +# provider crate, crates/permission-signal/tcf, so a deployment that runs no +# TCF carries no table of another scheme's numbers. Which schemes run at all, +# and in what order, is [permission_signal] sources in trusted-server.toml. signals: # A present TCF v2 record (a standalone TC string, or the EU TCF section of a - # GPP string). With authoritative true, each listed TCF purpose grants the - # Data Use it maps to when the record consents to that purpose, and revokes it - # otherwise; with authoritative false the record is ignored. The flag governs - # only the TCF record's own grants and revokes: an opt-out signal below always - # suppresses the Data Uses it revokes, even alongside a consenting TCF record, - # because an explicit opt-out is never overridden by another signal. This is - # the interim home for the purpose to Data Use mapping, and a purpose may - # grant one Data Use or a list of them. Once the IAB Privacy Taxonomy tcf - # column is finalized (fideslang) that becomes the single source and this - # block is dropped. + # GPP string). With authoritative true, the record's consent to a purpose + # grants the Data Uses the TCF provider maps to that purpose, and its refusal + # revokes them, whereas with authoritative false the record is ignored. + # The flag governs only whether the record answers. Whether its answer + # stands over an opt-out signal below, or the opt-out over it, is the order + # the providers are asked in, [permission_signal] sources in + # trusted-server.toml, where the last provider with an opinion decides. The + # default order asks Global Privacy Control first, being a browser setting + # with no interface of its own, and the schemes carrying a choice someone + # made through an interface after. tcf: authoritative: true - purposes: - 1: necessary.operations.storage - 2: - - advertising_marketing.first_party.contextual - - advertising_marketing.frequency_capping - - advertising_marketing.negative_targeting - 3: advertising_marketing.profiling - 4: - - advertising_marketing.first_party.targeted - - advertising_marketing.third_party.targeted - 5: advertising_marketing.personalize.profiling - 6: - - advertising_marketing.personalize.content - - advertising_marketing.personalize.system - - functional.personalization - 7: - - analytics.ad_reporting.measure_ad_performance - - analytics.ad_reporting.ad_delivery_and_targeting - - analytics.ad_reporting.ad_viewability - 8: analytics.ad_reporting.content_performance - 9: - - analytics.ad_reporting.market_research - - analytics.ad_reporting.campaign_insights - 10: necessary.operations.improve - 11: select-basic-content # US-style opt-out of sale or sharing. It applies when any listed source is - # set, and it suppresses the Data Uses it revokes even when a TCF record - # consents to them. The list below models the US state opt-out scope, being - # sale, sharing, targeted advertising, and device storage as the - # conservative reading, so an opted-out visitor gets no Edge Cookie - # written and no identifier shared, whilst contextual advertising and - # measurement continue after an opt-out. A deployer can widen the list, or write `revokes: all` to drop - # every granted Data Use. An opt-out suppresses use for the request; it is - # never a destructive withdrawal of an already-issued identifier. + # set, and its provider revokes the Data Uses listed here. Whether that + # revoke stands when a TCF record consents to the same Data Use depends on + # the provider order, see above. The list below models the US state opt-out + # scope, being sale, sharing, targeted advertising, and device storage as + # the conservative reading, so an opted-out visitor gets no Edge Cookie + # written and no identifier shared, while contextual advertising and + # measurement continue after an opt-out. A deployer can widen the list, or + # write `revokes: all` to drop every granted Data Use. An opt-out suppresses + # use for the request and is never a destructive withdrawal of an + # already-issued identifier. us_opt_out: sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] revokes: diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index df98bd7aa0..a1ca695c4a 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -345,8 +345,14 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b } /// Returns the effective decoded TCF consent for enforcement decisions. +/// +/// A standalone TC string wins, and the EU TCF section of a GPP string stands +/// in when there is none. Public because the TCF permission signal provider +/// lives outside core and reads the record this pipeline produced, rather than +/// decoding the cookie a second time and disagreeing with every other reader +/// of the same request about expiry and the cached record. #[must_use] -pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 7b1447d3b4..e0a6ee4e03 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -2,20 +2,26 @@ //! //! The Edge Cookie provider advertises the [`Permission`]s its data use //! requires. [`assemble_permissions`] resolves which permissions are set for a -//! request, from its session signals and the country it maps to, and the -//! context construction gates the provider on that state. The EC permission -//! decision lives here, in the EC subsystem, and nowhere else, so callers -//! route every EC permission check through this module rather than +//! request, from the country it maps to and what the signal providers say, +//! and the context construction gates the provider on that state. The EC +//! permission decision lives here, in the EC subsystem, and nowhere else, so +//! callers route every EC permission check through this module rather than //! re-deriving one. +//! +//! No scheme is decoded here. Which signals count, and what each says about +//! a permission, is answered by the [`PermissionSignalProvider`]s the adapter +//! hands in, every one of which is a crate outside core. This module asks +//! them in order and applies the country and region rules to what they +//! settle on. -use crate::consent::ConsentContext; -use crate::consent::jurisdiction::Jurisdiction; use std::sync::Arc; -use crate::permission_signal::{PermissionSignalSource, SignalInput}; +use crate::consent::ConsentContext; +use crate::consent::jurisdiction::Jurisdiction; +use crate::evidence::RequestInfo; +use crate::permission_signal::{self, PermissionSignalProvider}; use crate::permissions::{ - Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, - SignalPolicy, + Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionState, SignalPolicy, }; use crate::platform::GeoInfo; @@ -76,35 +82,34 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { } /// Assembles the permission state for a request: the place baseline from the -/// tree in `permissions.yaml`, augmented by the session's signals. +/// tree in `permissions.yaml`, amended by what the signal providers say. /// -/// Permissions exist without a consent model. With no signal present the result -/// is simply the baseline for the request's country and region. When the geo -/// provider resolves no location, or a country/region that has no rule, the -/// policy's top node applies, and the top node's `group` is required so one is -/// always available. A failed lookup ([`GeoStatus::Failed`]) instead resolves -/// every permission to the requires-signal floor, so an outage is handled -/// protectively rather than as the policy's declared default. -#[must_use] -pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { - assemble_permissions_with(consent, geo, &all_sources()) -} - -/// As [`assemble_permissions`], for a deployment that has named which signal -/// models run and in what order. +/// Permissions exist without a consent model. With no provider having an +/// opinion the result is simply the baseline for the request's country and +/// region. When the geo provider resolves no location, or a country/region +/// that has no rule, the policy's top node applies, and the top node's `group` +/// is required so one is always available. A failed lookup +/// ([`GeoStatus::Failed`]) instead resolves every permission to the +/// requires-signal floor, so an outage is handled protectively rather than as +/// the policy's declared default. /// -/// A model missing from `sources` does not run, so a publisher removes one by -/// leaving it out rather than by configuring it off. An empty slice runs none -/// of them, which leaves every permission at its country and region baseline. +/// `providers` are the signal providers the adapter selected, in the order +/// they run. A scheme missing from the list does not run, so a publisher +/// removes one by leaving it out rather than by configuring it off, and an +/// empty slice runs none of them, which leaves every permission at its +/// country and region baseline. The same providers answer whether storage +/// was explicitly withdrawn, recorded on the state and read through +/// [`PermissionState::storage_withdrawn`]. #[must_use] -pub fn assemble_permissions_with( +pub fn assemble_permissions( consent: &ConsentContext, + evidence: &dyn RequestInfo, geo: GeoStatus<'_>, - sources: &[Arc], + providers: &[Arc], ) -> PermissionState { let maps = PermissionMaps::standard(); - let signal = permission_signal(consent, maps.signals(), sources); - match geo { + let signal = permission_signal(consent, evidence, maps.signals(), providers); + let state = match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { let info = geo.info(); @@ -114,7 +119,16 @@ pub fn assemble_permissions_with( signal, ) } - } + }; + let withdrawn = permission_signal::withdrawn( + providers, + Permission::StoreOnDevice, + consent, + evidence, + maps.signals(), + storage_acquisition(geo), + ); + state.with_storage_withdrawn(withdrawn) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -142,19 +156,17 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { } } -/// Maps a consent context to a [`ConsentSignal`] for each permission, applying -/// the [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// Maps a request to a [`ConsentSignal`] for each permission, applying the +/// [`SignalPolicy`] the permission model parsed from `permissions.yaml`. /// -/// This is the only place the EC subsystem reads consent signals. The policy, -/// not this function, decides which sources are authoritative, which TCF purpose -/// maps to which Data Use, and what a US-style opt-out revokes. This function -/// only decodes the request and applies that policy, so no signal-to-permission -/// policy lives in the code. +/// This is the only place the EC subsystem consults the signal providers. The +/// policy, not this function, decides which schemes are authoritative and what +/// a US-style opt-out revokes, and each provider decides what its own scheme +/// says. This function only hands each provider the request, in order, so no +/// signal-to-permission rule lives in core. /// -/// The models it asks are the ones core supplies (see [`builtin_sources`]), -/// each of which amends what the ones before it settled on. The order is the -/// policy, and [`combine`] documents why. This function only assembles the -/// list and hands each source the request. +/// Each provider amends what the ones before it settled on. The order is the +/// policy, and [`combine`] documents why. /// /// Whether an amendment changes anything is then decided by the country/region /// map, which drops a `granted` baseline on a `Revoke` and has nothing to drop @@ -163,259 +175,81 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { /// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, signals: &'a SignalPolicy, - sources: &'a [Arc], + providers: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { // A record that arrived and could not be read fails closed, ahead of - // every configured model and regardless of which are configured. + // every configured provider and regardless of which are configured. // - // This is not a signalling model and is deliberately not in the - // configured list. A publisher chooses which signals to act on; they do - // not choose what happens when one of those signals arrives unreadable. - // An unreadable record is a preference someone expressed that cannot be + // This is not a signaling scheme and is deliberately not in the + // configured list. A publisher chooses which signals to act on, but + // not what happens when one of those signals arrives unreadable. An + // unreadable record is a preference someone expressed that cannot be // read, which is different from no record at all, so it must not // degrade to the no-signal baseline. // // It overrides rather than taking a place in the order because the - // ordered rule would otherwise let a readable record from one model + // ordered rule would otherwise let a readable record from one scheme // overwrite the refusal caused by an unreadable one from another. if consent.has_malformed_record() { return ConsentSignal::Revoke; } - crate::permission_signal::combine(sources, permission, consent, signals, baseline) - } -} - -/// The identifiers of every signal model core supplies, in the default order. -/// -/// Configuration names sources from this list, and -/// [`PermissionSignalConfig::validate_selection`] rejects a name that is not in -/// it at startup rather than silently ignoring it. -/// -/// [`PermissionSignalConfig::validate_selection`]: -/// crate::settings::PermissionSignalConfig::validate_selection -pub const SOURCE_IDS: &[&str] = &["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"]; - -/// Every signal model core supplies, in the default order. -/// -/// These were decoded inline until the seam existed. They are the same rules, -/// moved behind [`PermissionSignalSource`] so a further model can be added by a -/// module instead of by editing core. -/// -/// The order runs the signals needing no interaction before the ones following -/// a prompt, so a visitor who arrives with an opt-out and then answers a prompt -/// has their answer applied. A deployment wanting the opposite reorders the -/// list in configuration. -/// -/// [`PermissionSignalSource`]: crate::permission_signal::PermissionSignalSource -#[must_use] -pub fn all_sources() -> Vec> { - vec![ - Arc::new(GpcSource), - Arc::new(GppSaleOptOutSource), - Arc::new(UsPrivacySource), - Arc::new(TcfSource), - ] -} - -/// The sources a deployment named, in the order it named them. -/// -/// `None` means nothing was configured, which runs all of them in the default -/// order. That is deliberate: a publisher gets every model the build knows -/// about until they say otherwise, so a signal is never quietly ignored because -/// someone forgot to list it. -/// -/// A name that matches nothing is dropped here, having already been rejected at -/// startup by [`PermissionSignalConfig::validate_selection`]. -/// -/// [`PermissionSignalConfig::validate_selection`]: -/// crate::settings::PermissionSignalConfig::validate_selection -#[must_use] -pub fn sources_for(configured: Option<&[String]>) -> Vec> { - let all = all_sources(); - let Some(names) = configured else { - return all; - }; - names - .iter() - .filter_map(|name| { - all.iter() - .find(|source| source.id() == name.as_str()) - .map(Arc::clone) - }) - .collect() -} - -/// Whether one US-style opt-out takes `permission` away on this request. -/// -/// Shared by the three sources below, which differ only in the signal they -/// read. They are separate sources rather than one so that a publisher who does -/// not want to act on Global Privacy Control can leave that source out of the -/// configured list without also losing the GPP and US Privacy opt-outs. -/// -/// Which signals count at all, and what an opt-out takes away, remain the -/// policy's decisions. A source that the policy does not list stays silent even -/// when configuration names it, so removing it from `opt_out_sources` in -/// `permissions.yaml` and leaving it out of the list have the same effect. -fn opt_out_signal( - source: OptOutSource, - permission: Permission, - input: &SignalInput<'_>, -) -> ConsentSignal { - if input.policy.opt_out_sources().contains(&source) - && opt_out_present(input.consent, &[source]) - && input.policy.opt_out_revokes(permission) - { - return ConsentSignal::Revoke; - } - ConsentSignal::Neutral -} - -/// The `Sec-GPC` request header, Global Privacy Control. -struct GpcSource; - -impl PermissionSignalSource for GpcSource { - fn id(&self) -> &'static str { - "gpc" - } - - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::Gpc, permission, input) + permission_signal::combine(providers, permission, consent, evidence, signals, baseline) } } -/// A GPP US sale opt-out. -struct GppSaleOptOutSource; +#[cfg(test)] +mod tests { + use http::HeaderMap; -impl PermissionSignalSource for GppSaleOptOutSource { - fn id(&self) -> &'static str { - "gpp-sale-opt-out" - } + use super::*; + use crate::evidence::OwnedRequestInfo; + use crate::permission_signal::SignalInput; + use crate::test_support::tests::create_test_settings; - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::GppSaleOptOut, permission, input) - } -} + /// A provider that grants every permission, standing in for a scheme that + /// answered a prompt, so the assembly rules can be exercised without any + /// real scheme in core. + struct Granting; -/// A US Privacy string sale opt-out. -struct UsPrivacySource; + impl PermissionSignalProvider for Granting { + fn id(&self) -> &'static str { + "granting" + } -impl PermissionSignalSource for UsPrivacySource { - fn id(&self) -> &'static str { - "us-privacy" + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Grant + } } - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - opt_out_signal(OptOutSource::UsPrivacyOptOut, permission, input) + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) } -} -/// TCF v2, when the policy says TCF answers for this deployment. -/// -/// The mapping from permission to purpose is the policy's, so this source -/// decodes and does not interpret. A permission no purpose maps to gets -/// silence, not a refusal. -struct TcfSource; - -impl PermissionSignalSource for TcfSource { - fn id(&self) -> &'static str { - "tcf" + fn granting() -> Vec> { + vec![Arc::new(Granting)] } - fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { - if !input.policy.tcf_authoritative() { - return ConsentSignal::Neutral; - } - let Some(tcf) = crate::consent::effective_tcf(input.consent) else { - return ConsentSignal::Neutral; - }; - match input.policy.tcf_purpose(permission) { - Some(purpose) => { - if tcf.has_purpose_consent(usize::from(purpose)) { - ConsentSignal::Grant - } else { - // A purpose the visitor did not consent to is a refusal, - // not silence. Reading it as silence would leave the place - // baseline standing and grant what they declined. - ConsentSignal::Revoke - } - } - None => ConsentSignal::Neutral, - } + fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + providers: &[Arc], + ) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, providers) } -} - -/// Whether the request carries any of the `sources` a US-style opt-out is -/// declared to use. Decoding only, so the policy (not this function) decides -/// which sources count and what the opt-out revokes. -fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { - sources.iter().any(|source| match source { - OptOutSource::Gpc => consent.gpc, - OptOutSource::GppSaleOptOut => { - consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) - } - OptOutSource::UsPrivacyOptOut => consent - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), - }) -} -/// Reports whether the request carries an explicit signal withdrawing Edge -/// Cookie storage, rather than merely lacking the permission. -/// -/// This separates an affirmative withdrawal (which expires the browser cookie -/// and writes the authoritative identity-graph tombstone) from suppression, -/// where the permission is simply not set for this request (which strips EC -/// response headers but must not destroy an already-issued identifier, or a -/// returning user would be permanently withdrawn before they ever get to -/// consent). -/// -/// Only a TCF record refusing storage (Purpose 1) withdraws, and only where -/// the jurisdiction's storage baseline is not `granted`: under a -/// `requires_signal` baseline the refusal is the visitor declining the very -/// signal storage depends on, while under a `granted` baseline storage never -/// depended on the record, so the refusal suppresses use without destroying -/// the identifier. US-style opt-outs (GPC, a GPP sale opt-out, or a US -/// Privacy opt-out) suppress the permissions the policy revokes but are -/// never destructive, and no signal at all is not a withdrawal. -#[must_use] -pub fn ec_storage_withdrawn(consent: &ConsentContext, storage_baseline: Acquisition) -> bool { - if let Some(tcf) = crate::consent::effective_tcf(consent) { - return !tcf.has_storage_consent() && !matches!(storage_baseline, Acquisition::Granted); - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::consent::TcfConsent; - use crate::test_support::tests::create_test_settings; - - /// Builds a minimal decoded TCF record consenting to the given 1-indexed - /// purposes, with everything else refused. - fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { - let mut purpose_consents = vec![false; 24]; - for &purpose in consented { - purpose_consents[purpose - 1] = true; - } - TcfConsent { - version: 2, - cmp_id: 0, - cmp_version: 0, - consent_screen: 0, - consent_language: "EN".to_owned(), - vendor_list_version: 0, - tcf_policy_version: 2, - created_ds: 0, - last_updated_ds: 0, - purpose_consents, - purpose_legitimate_interests: vec![false; 24], - vendor_consents: Vec::new(), - vendor_legitimate_interests: Vec::new(), - special_feature_opt_ins: vec![false; 12], + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, } } @@ -424,37 +258,24 @@ mod tests { let settings = create_test_settings(); // The test settings select the HMAC provider, which requires // necessary.operations.storage. The policy's top node resolves storage - // as requires-signal, so with no signal the permission is not set and - // the provider's requirement is not met. + // as requires-signal, so with no provider granting it the permission is + // not set and the provider's requirement is not met. let provider = crate::ec::provider::build_provider(&settings.ec, None, None) .expect("should build the configured provider") .expect("should select the hmac provider"); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); assert!( !state.all_set(provider.required_permissions()), "the requires-signal default should not satisfy the HMAC provider without a signal" ); } - fn us_ca_geo() -> GeoInfo { - GeoInfo { - city: String::new(), - country: "US".to_owned(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: Some("CA".to_owned()), - asn: None, - } - } - #[test] fn no_signal_uses_the_us_opt_out_baseline() { // US/CA maps to the us-opt-out group, where every purpose is granted // without a signal, so EC identity and bidstream EIDs are both permitted. let geo = us_ca_geo(); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)); + let state = assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]); assert!( state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), @@ -463,29 +284,31 @@ mod tests { } #[test] - fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { - // A US-style opt-out drops a granted baseline with no jurisdiction match: - // the map granted these purposes, and GPC revokes them. - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + fn a_grant_sets_a_requires_signal_permission() { + // The top node requires a signal for storage, and a provider granting + // it is what a signal arriving looks like from here. + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &granting(), + ); assert!( - !state.is_set(Permission::StoreOnDevice) - && !state.is_set(Permission::SelectPersonalisedAds), - "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + state.is_set(Permission::StoreOnDevice), + "a provider granting storage should set it under a requires-signal baseline" + ); + assert!( + !state.storage_withdrawn(), + "and a grant is the opposite of a withdrawal" ); } // ------------------------------------------------------------------ - // An unreadable record. Not a model a publisher lists, so it applies + // An unreadable record. Not a scheme a publisher lists, so it applies // whatever they configured, and it is not subject to the ordering. // ------------------------------------------------------------------ #[test] - fn an_unreadable_record_revokes_even_with_no_models_configured() { + fn an_unreadable_record_revokes_even_with_no_providers_configured() { let consent = ConsentContext { raw_tc_string: Some("this is not a TC string".to_owned()), ..ConsentContext::default() @@ -495,7 +318,7 @@ mod tests { "the fixture has to actually be unreadable for this to test anything" ); let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); + let state = assembled(&consent, GeoStatus::Located(&geo), &[]); assert!( !state.is_set(Permission::StoreOnDevice), "a preference someone expressed that cannot be read must not degrade to the \ @@ -506,392 +329,71 @@ mod tests { #[test] fn a_readable_record_does_not_overwrite_an_unreadable_one() { // The regression the override exists to prevent: under the ordered - // rule alone, a TCF record that consents would be asked after the + // rule alone, a provider that grants would be asked after the // unreadable GPP string and would overwrite the refusal it caused. let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), raw_gpp_string: Some("this is not a GPP string".to_owned()), ..ConsentContext::default() }; - assert!(consent.has_malformed_record()); - let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); - assert!( - !state.is_set(Permission::StoreOnDevice), - "one model arriving unreadable is not cured by another model arriving readable" - ); - } - - // ------------------------------------------------------------------ - // Which models run. A publisher names them in [permission_signal] - // sources, and one left off the list does not run at all. - // ------------------------------------------------------------------ - - /// Every identifier except the one named, in the declared order. - fn every_source_except(excluded: &str) -> Vec { - SOURCE_IDS - .iter() - .filter(|id| **id != excluded) - .map(|id| (*id).to_owned()) - .collect() - } - - #[test] - fn the_declared_identifiers_match_the_models_that_run() { - // Configuration is validated against SOURCE_IDS and resolved against - // all_sources, so the two drifting apart would let a name validate and - // then match nothing, silently dropping a model. - let running: Vec<&str> = all_sources().iter().map(|source| source.id()).collect(); - assert_eq!( - running, SOURCE_IDS, - "SOURCE_IDS is what configuration is checked against, so it has to be what runs" - ); - } - - #[test] - fn naming_nothing_runs_every_model() { - let ids: Vec<&str> = sources_for(None).iter().map(|source| source.id()).collect(); - assert_eq!( - ids, SOURCE_IDS, - "a publisher who configures nothing acts on every signal the build knows, so \ - one is never ignored because they forgot to list it" - ); - } - - #[test] - fn the_configured_order_is_the_order_they_are_asked_in() { - let reversed: Vec = SOURCE_IDS.iter().rev().map(|id| (*id).to_owned()).collect(); - let ids: Vec = sources_for(Some(&reversed)) - .iter() - .map(|source| source.id().to_owned()) - .collect(); - assert_eq!( - ids, reversed, - "the list is the order, not merely the membership" - ); - } - - #[test] - fn a_model_left_off_the_list_does_not_run() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - - let everything = - assemble_permissions_with(&consent, GeoStatus::Located(&geo), &all_sources()); - assert!( - !everything.is_set(Permission::StoreOnDevice), - "with every model running, the header takes storage away" - ); - - let without_gpc = every_source_except("gpc"); - let pruned = assemble_permissions_with( - &consent, - GeoStatus::Located(&geo), - &sources_for(Some(&without_gpc)), - ); assert!( - pruned.is_set(Permission::StoreOnDevice), - "a publisher who does not want to act on Global Privacy Control removes it from \ - the list, and the header then changes nothing" + consent.has_malformed_record(), + "the fixture has to actually be unreadable for this to test anything" ); - } - - #[test] - fn removing_one_opt_out_leaves_the_others_working() { - // The reason the three opt-outs are separate sources rather than one. - let consent = ConsentContext { - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; let geo = us_ca_geo(); - let without_gpc = every_source_except("gpc"); - let state = assemble_permissions_with( - &consent, - GeoStatus::Located(&geo), - &sources_for(Some(&without_gpc)), - ); + let state = assembled(&consent, GeoStatus::Located(&geo), &granting()); assert!( !state.is_set(Permission::StoreOnDevice), - "dropping Global Privacy Control must not drop the US Privacy opt-out with it" - ); - } - - #[test] - fn running_no_models_leaves_the_place_baseline() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions_with(&consent, GeoStatus::Located(&geo), &[]); - assert!( - state.is_set(Permission::StoreOnDevice), - "an empty list is a publisher acting on no signal at all, so only the country \ - and region rules apply" + "one scheme arriving unreadable is not cured by another scheme granting" ); } #[test] - fn an_unknown_name_resolves_to_nothing_rather_than_a_wrong_model() { - // Startup validation rejects this first. The check here is that if one - // ever reached this far it would drop out rather than match by position. - let named = vec!["not-a-source".to_owned(), "tcf".to_owned()]; - let ids: Vec<&str> = sources_for(Some(&named)) - .iter() - .map(|source| source.id()) - .collect(); - assert_eq!(ids, vec!["tcf"]); - } - - // ------------------------------------------------------------------ - // Opt-out and prompt precedence. - // - // These replace an earlier set asserting the opposite, that an opt-out - // suppressed storage and sharing whatever else the request carried. The - // sources are now asked in order and each amends what the ones before it - // settled, so a later source can amend an opt-out. - // - // The default order asks the signals needing no interaction first and the - // ones following a prompt after, which is why a visitor who arrives with - // an opt-out and then answers a prompt has their answer applied. A - // deployment wanting the opposite puts the opt-out source last. - // - // The layering, the configuration and what a source may consult are in - // crates/trusted-server-core/src/permission_signal/README.md. - // ------------------------------------------------------------------ - - #[test] - fn a_prompt_answer_applies_over_a_gpc_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpc: true, + fn a_malformed_gpp_or_us_privacy_record_is_detected() { + // Each undecodable record form has to be detected, or the override + // above would not fire for it. + let gpp = ConsentContext { + raw_gpp_string: Some("not-a-gpp-string".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with GPC set, and under the \ - default order the answer they gave is applied over the header they sent" - ); - } - - #[test] - fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), + let usp = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with a US Privacy opt-out, and under the \ - default order the answer they gave amends the signal they sent" - ); - } - - #[test] - fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpp: Some(crate::consent::types::GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - state.is_set(Permission::StoreOnDevice), - "the visitor answered a prompt after arriving with a GPP sale opt-out, and under the \ - default order the answer they gave amends the signal they sent" + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" ); - } - - #[test] - fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { - let consent = ConsentContext { - gpc: true, - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::No, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); assert!( - !state.is_set(Permission::StoreOnDevice), - "any one opt-out source should suppress, whatever the others say" - ); - } - - // ------------------------------------------------------------------ - // Withdrawal scoping: only a TCF storage refusal withdraws, and only - // where the baseline did not grant storage outright. Opt-outs suppress - // use but never destroy an already-issued identifier. - // ------------------------------------------------------------------ - - #[test] - fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; - assert!( - ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "refusing the signal storage depends on should withdraw" - ); - } - - #[test] - fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::Granted), - "storage never depended on the record here, so refusal suppresses without destroying" - ); - } - - #[test] - fn tcf_storage_consent_is_not_a_withdrawal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1])), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "a consenting record is not a withdrawal" - ); - } - - #[test] - fn gpc_alone_never_withdraws() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::Granted) - && !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "GPC suppresses use for the request but never destroys the identifier" - ); - } - - #[test] - fn us_style_opt_outs_never_withdraw() { - let consent = ConsentContext { - us_privacy: Some(crate::consent::types::UsPrivacy { - version: 1, - notice_given: crate::consent::PrivacyFlag::Yes, - opt_out_sale: crate::consent::PrivacyFlag::Yes, - lspa_covered: crate::consent::PrivacyFlag::NotApplicable, - }), - gpp: Some(crate::consent::types::GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "sale opt-outs suppress use but never destroy the identifier" - ); - } - - #[test] - fn no_signal_is_not_a_withdrawal() { - assert!( - !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), - "absence of a signal must never destroy an identifier" - ); - } - - #[test] - fn a_malformed_record_is_not_a_withdrawal() { - let consent = ConsentContext { - raw_tc_string: Some("not-a-tc-string".to_owned()), - ..ConsentContext::default() - }; - assert!( - !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), - "an unreadable record fails closed (suppression), not destructively" + !assembled(&usp, GeoStatus::Located(&geo), &granting()) + .is_set(Permission::StoreOnDevice), + "an unreadable US Privacy string blocks the granted baseline like any other record" ); } - // ------------------------------------------------------------------ - // Malformed-but-present records block baseline grants (fail closed) - // instead of degrading to the no-signal baseline. - // ------------------------------------------------------------------ - #[test] - fn a_malformed_tcf_record_blocks_baseline_grants() { + fn an_unreadable_record_is_not_a_withdrawal() { let consent = ConsentContext { raw_tc_string: Some("not-a-tc-string".to_owned()), ..ConsentContext::default() }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - assert!( - !state.is_set(Permission::StoreOnDevice), - "an unreadable record should block the granted baseline, not vanish" - ); - } - - #[test] - fn a_malformed_gpp_or_us_privacy_record_is_detected() { - let gpp = ConsentContext { - raw_gpp_string: Some("not-a-gpp-string".to_owned()), - ..ConsentContext::default() - }; - let usp = ConsentContext { - raw_us_privacy: Some("bogus".to_owned()), - ..ConsentContext::default() - }; + let state = assembled(&consent, GeoStatus::NoLocation, &granting()); assert!( - gpp.has_malformed_record() && usp.has_malformed_record(), - "each undecodable record form should be detected" + !state.storage_withdrawn(), + "an unreadable record fails closed by suppression, never destructively" ); } #[test] - fn an_expired_tcf_record_is_not_treated_as_malformed() { - let consent = ConsentContext { - raw_tc_string: Some("CPc-old-string".to_owned()), - expired: true, - ..ConsentContext::default() - }; + fn running_no_providers_leaves_the_place_baseline() { let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + let state = assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]); assert!( state.is_set(Permission::StoreOnDevice), - "expiry is its own explicit state, deliberately distinct from malformed" + "an empty list is a publisher acting on no signal at all, so only the country \ + and region rules apply" ); + assert!(!state.storage_withdrawn(), "and nothing can have withdrawn"); } // ------------------------------------------------------------------ @@ -907,11 +409,11 @@ mod tests { // other, so nothing is set without a signal. let geo = us_ca_geo(); assert!( - assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)) + assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]) .is_set(Permission::StoreOnDevice), "the located baseline must grant storage, or this test proves nothing" ); - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Failed); + let state = assembled(&ConsentContext::default(), GeoStatus::Failed, &[]); assert!( !state.is_set(Permission::StoreOnDevice), "a lookup failure must not fall back to any node of the policy tree" @@ -928,7 +430,7 @@ mod tests { // The shipped policy's top node is the gdpr-eu group, which requires a // signal for storage, so an unplaced visitor gets no identifier until // one arrives. - let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); assert!( !state.is_set(Permission::StoreOnDevice), "the top node requires a signal for storage" @@ -953,36 +455,4 @@ mod tests { "a failed lookup must not adopt the policy's declared jurisdiction" ); } - - #[test] - fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { - // A TCF record now grants or revokes every one of the eleven mapped - // purposes, not only Purpose 1 and Purpose 4. Consent to all purposes - // except Purpose 7 (measure ad performance), in a US opt-out state where - // the baseline granted them all, so a revoke is observable as a drop. - let consented: Vec = (1..=11).filter(|&p| p != 7).collect(); - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&consented)), - ..ConsentContext::default() - }; - let geo = us_ca_geo(); - let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); - - // Purpose 2 is now resolved (it was neutral before), so consent sets it. - assert!( - state.is_set(Permission::SelectBasicAds), - "Purpose 2 consent should set advertising_marketing.first_party.contextual" - ); - // Purpose 7 was refused, so the granted baseline is revoked. - assert!( - !state.is_set(Permission::MeasureAdPerformance), - "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" - ); - // The originally wired purposes still behave. - assert!( - state.is_set(Permission::StoreOnDevice) - && state.is_set(Permission::SelectPersonalisedAds), - "Purposes 1 and 4 remain resolved from the TCF record" - ); - } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 191bb7c84b..f0625238b8 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -476,9 +476,11 @@ mod tests { fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); - // A TCF record refusing storage is the withdrawal trigger. The test - // context resolves the storage baseline at the requires-signal floor, - // where refusing the signal storage depends on is destructive. + // A TCF record refusing storage is the withdrawal trigger, under a + // storage baseline at the requires-signal floor, where refusing the + // signal storage depends on is destructive. The TCF provider answers + // that at assembly, and core links no provider, so the answer is + // stated here and what finalization does with it is what is tested. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, tcf: Some(refusing_tcf()), @@ -486,7 +488,8 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -853,7 +856,10 @@ mod tests { source: ConsentSource::Cookie, ..Default::default() }; - let ec_context = canonicalizing_context(true, false, consent, false); + // The TCF provider answers the withdrawal at assembly, and core links + // no provider, so the answer is stated here. + let ec_context = canonicalizing_context(true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( @@ -1127,6 +1133,8 @@ mod tests { // created, but the deployment now runs a provider with a different // code, so read-back treats the cookie as absent and the active // identifier is empty. + // The TCF provider answers the withdrawal at assembly, and core links + // no provider, so the answer is stated here. let ec_context = make_context_with_consent( None, Some(CANONICAL_COOKIE_VALUE), @@ -1135,7 +1143,8 @@ mod tests { consent, false, ) - .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 91a5210a2e..cb50db81ab 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -76,7 +76,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; -use crate::permissions::{Acquisition, Permission, PermissionState}; +use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -149,11 +149,6 @@ pub struct EcContext { /// augmented by the session's signals. Assembled once at construction and /// read via [`permissions`](Self::permissions). permissions: PermissionState, - /// The jurisdiction's acquisition rule for Edge Cookie storage, resolved - /// once at construction and used by - /// [`storage_withdrawn`](Self::storage_withdrawn) to scope destructive - /// withdrawal. Defaults to the requires-signal floor. - storage_acquisition: Acquisition, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -347,16 +342,24 @@ impl EcContext { }); // Assemble the permission state once, here, through the permission - // model, building the country/region baseline augmented by the session's - // signals. Downstream consumers read the stored result via + // model, building the country/region baseline amended by what the + // signal providers the adapter selected say about the request. + // Downstream consumers read the stored result via // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than - // re-deriving it. - let permissions = consent::assemble_permissions_with( + // re-deriving it. The providers see the request as evidence, the same + // abstraction the Edge Cookie and device providers read, so a scheme + // core has never heard of can read its own signal from it. + let evidence = crate::evidence::BorrowedRequestInfo::new( + client_ip.as_deref().unwrap_or_default(), + Some(req.headers()), + ) + .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let permissions = consent::assemble_permissions( &consent, + &evidence, geo_status, - &consent::sources_for(settings.permission_signal.sources.as_deref()), + services.permission_signal_providers(), ); - let storage_acquisition = consent::storage_acquisition(geo_status); // With no provider selected nothing may create or use an identifier, so // the gate is closed rather than open by default. let ec_allowed = selected_provider @@ -379,7 +382,6 @@ impl EcContext { consent, ec_allowed, permissions, - storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -757,13 +759,15 @@ impl EcContext { /// Whether the request carries an explicit signal withdrawing Edge Cookie /// storage, scoped to the jurisdiction's storage baseline. /// - /// See [`consent::ec_storage_withdrawn`]: only a TCF record refusing - /// storage withdraws, and only where the storage baseline is not - /// `granted`. Suppression (the permission merely not set) is reported by + /// Answered by the signal providers at construction and recorded on the + /// permission state, see [`PermissionState::storage_withdrawn`]. Of the + /// schemes that ship, only a TCF record refusing storage withdraws, and + /// only where the storage baseline is not `granted`. Suppression (the + /// permission merely not set) is reported by /// [`ec_allowed`](Self::ec_allowed) being `false` instead. #[must_use] pub fn storage_withdrawn(&self) -> bool { - consent::ec_storage_withdrawn(&self.consent, self.storage_acquisition) + self.permissions.storage_withdrawn() } /// Whether the Edge Cookie identifier may be shared beyond the edge for @@ -864,7 +868,6 @@ impl EcContext { consent, ec_allowed, permissions, - storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -891,7 +894,6 @@ impl EcContext { ec_generated: false, consent, ec_allowed: true, - storage_acquisition: Acquisition::default(), permissions: PermissionState::default(), client_ip, geo_info: None, @@ -924,7 +926,6 @@ impl EcContext { consent, ec_allowed, permissions: PermissionState::default(), - storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, @@ -935,6 +936,21 @@ impl EcContext { response_headers: Vec::new(), } } + + /// The same context, recording that the request explicitly withdrew + /// storage, as assembly records it when a signal provider answers so. + /// + /// Core links no provider, so a core test cannot derive the withdrawal + /// from a consent record the way a deployment does through the TCF + /// provider. It states the answer instead and tests what finalization does + /// with it. That a TCF refusal produces this answer is proved where the + /// providers are linked, in the Axum adapter's `permission_signals` test. + #[cfg(test)] + #[must_use] + pub fn with_storage_withdrawn_for_test(mut self, withdrawn: bool) -> Self { + self.permissions = self.permissions.with_storage_withdrawn(withdrawn); + self + } } /// Returns the current Unix timestamp in seconds. diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index ed079e7d49..c1aa144a76 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -68,6 +68,23 @@ pub trait RequestInfo: Send + Sync + core::fmt::Debug { url::form_urlencoded::parse(self.query().as_bytes()) .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) } + + /// The value of request cookie `name`, or `None` when the request does not + /// carry it. + /// + /// Defaulted, parsing the `Cookie` header, so a provider that needs a + /// cookie does not write its own splitting and trimming and get it subtly + /// different from the next one. An implementation holding a parsed jar may + /// override it. Core keeps no list of which cookies belong to which + /// scheme, which is what lets a permission signal provider for a scheme + /// core has never heard of read its own signal. + fn cookie(&self, name: &str) -> Option<&str> { + let header = self.header("cookie")?; + header.split(';').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key.trim() == name).then(|| value.trim()) + }) + } } /// An owned [`RequestInfo`] built from a request snapshot. @@ -250,6 +267,65 @@ pub trait HostSignals: Send + Sync + core::fmt::Debug { fn h2(&self) -> Option<&str>; } +#[cfg(test)] +mod cookie_tests { + use http::HeaderMap; + + use super::*; + + fn with_cookie_header(value: &'static str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert("cookie", http::HeaderValue::from_static(value)); + OwnedRequestInfo::new(String::new(), headers) + } + + #[test] + fn reads_a_cookie_by_name_from_among_several() { + let info = with_cookie_header("a=1; euconsent-v2=CP-abc; b=2"); + assert_eq!( + info.cookie("euconsent-v2"), + Some("CP-abc"), + "the named cookie is found wherever it sits in the header" + ); + } + + #[test] + fn trims_the_spaces_browsers_put_around_pairs() { + let info = with_cookie_header(" a = 1 ; b=2 "); + assert_eq!( + info.cookie("a"), + Some("1"), + "spaces around the name and value are not part of either" + ); + assert_eq!(info.cookie("b"), Some("2")); + } + + #[test] + fn answers_nothing_for_a_cookie_that_is_absent() { + let info = with_cookie_header("a=1"); + assert_eq!( + info.cookie("b"), + None, + "an absent cookie is None, not an empty value" + ); + assert_eq!( + OwnedRequestInfo::default().cookie("a"), + None, + "and so is a request with no Cookie header at all" + ); + } + + #[test] + fn matches_the_whole_name_and_not_a_prefix() { + let info = with_cookie_header("session_id=abc; id=xyz"); + assert_eq!( + info.cookie("id"), + Some("xyz"), + "`id` must not match `session_id`" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 4dd0fdfdf5..888385777c 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -10,105 +10,178 @@ answer anyone gave to a question, and a jurisdiction rule is neither. Consent is one kind of signal, so the seam takes the wider name and the consent subsystem keeps the narrower one. +## Why the providers are not in core + +Privacy is a non-price factor of competition. Publishers, browsers and +standards bodies compete on it, and schemes come and go. Compiling a closed +list of schemes into core would settle that competition in code, because +the schemes built in would be the only ones a deployment could act on, and +the core maintainers would be deciding which privacy schemes exist. + +So core holds the trait, the ordering, the country baseline, and the policy +vocabulary for what a deployment decides about the shipped schemes, being +whether a TCF record answers, which signals count as a US-style opt-out and +what an opt-out takes away. It holds no scheme's wire format and no scheme's +meaning. Every scheme lives in its own crate outside core, including the four +that ship by default, so none of them is privileged by being the one that +happens to be built in. Core does not know what a TCF purpose is, because the +mapping from purpose to Data Use lives in the TCF crate, and a deployment +that runs no TCF carries no table of another scheme's numbers. The purposes +are the IAB TCF Europe purposes and what they grant are IAB Tech Lab Privacy +Taxonomy Data Uses, so a reader checks the mapping against the industry's own +documents rather than against us, and docs/guide/permission-model.md lists it +in full. Two purposes have no Data Use yet, so the crate carries a proposed +key for one and the TCF identifier for the other until the taxonomy adds +them. + +A scheme that is not one of the four is added the same way, as a crate that +reads its own signal from the request, without a change to core. The next one +is Model Terms for Marketing (MTM), where a publisher and the parties it passes +data to agree to be bound by a published set of terms, and what a provider +reads is whether that agreement covers this request. MTM arrives in a following +pull request, so the four here are a starting set and not the list. + ## The hierarchy Permissions are resolved in layers, each amending the one before. ```text - country / region rules the baseline: granted, requires-signal, denied + country / region rules the baseline: granted, requires-signal, denied | v - source 1 (configured order) may amend + provider 1 (configured order) may amend | v - source 2 may amend + provider 2 may amend | v - ... may amend + ... may amend | v the permission state for this request ``` The baseline comes from `permissions.yaml`, keyed by country and region, with -a default for a request whose place is unknown. A geo provider supplies the -place. No geo provider means no country, and the baseline falls to its floor. +the top node of the rules tree standing in for a request whose place is +unknown. A geo provider supplies the place. No geo provider means no country, +so every request resolves at that top node, and only a lookup that failed +resolves at the requires-signal floor, because a place that could not be +determined must not be treated as the declared default. -Sources are then asked in order. Each sees what the sources before it settled -on and may amend it. A source with no opinion returns `Neutral` and leaves the -prior value standing, which is different from refusing. +Providers are then asked in order. Each sees what the providers before it +settled on and may amend it. A provider with no opinion returns `Neutral` and +leaves the prior value standing, which is different from refusing. ## The order is the policy -The last source with an opinion decides, so the order is the policy. It is a -deployment's to set, not this code's to assume. +The last provider with an opinion decides, so the order is the policy. It is +a deployment's to set, not this code's to assume. ```toml [permission_signal] sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] ``` -A source not on the list does not run, and there is no separate switch. A +A provider not on the list does not run, and there is no separate switch. A publisher who does not want to act on Global Privacy Control removes `"gpc"` -from the list. Leaving the section out entirely runs every model the build -knows about, in the default order, so a signal is never quietly ignored -because someone forgot to list it. An unknown or repeated name is refused at -startup, so a typo cannot silently stop a model being honored. - -The default order asks the signals needing no interaction first and the ones -following a prompt after. Global Privacy Control withdraws personalisation on -arrival, and a visitor who then answers a prompt has their answer applied over -it. A deployment wanting the opposite puts the opt-out source last. - -Trusted Server takes no view on which model should win. That is a question +from the list, and the provider that reads the header then does not run. One +caveat: core's consent pipeline can also synthesize a US Privacy opt-out from +that header for a visitor in a US state, when the consent settings say to, +which they do by default, and the `us-privacy` provider then acts on the +record it produced. A publisher who wants the header to have no effect at all +turns that setting off as well. Leaving the section out entirely runs every +provider the adapter offers, in the order it offers them, so a signal is never +quietly ignored because someone forgot to list it. An unknown or repeated name +is refused at startup, so a typo cannot silently stop a scheme being honored. + +The default order asks the signal with no interface of its own first and the +ones carrying a choice made through an interface after. Global Privacy +Control is a browser setting, so it revokes personalization on arrival, +whereas a GPP sale opt-out, a US Privacy string and a TCF record each carry +an answer a person gave, so they are asked later and amend it. A deployment +wanting the browser setting to stand over a later answer puts `gpc` last. + +Trusted Server takes no view on which scheme should win. That is a question about a jurisdiction and a publisher. -## A source can see the others +## A provider can see the others -Amending well sometimes needs to know who set the prior value. A source is +Amending well sometimes needs to know who set the prior value. A provider is given the whole ordered list and its own position in it, so it can look up a peer by name, see whether a peer it cares about is configured at all, and ask a peer directly what that peer makes of a permission. -That is what makes a rule like "personalisation is off, but only because +That is what makes a rule like "personalization is off, but only because Global Privacy Control set it, so my answer supersedes it" expressible. The -rule itself belongs to whichever source wants it. This seam only makes the +rule itself belongs to whichever provider wants it. This seam only makes the information available. -Consulting a peer goes one level deep. A source answering a consultation -cannot consult in turn, so two sources asking each other cannot loop. - -## Writing a source - -Answer `Neutral` for a permission the source has no opinion on, including when -the signal it reads is absent from the request. Returning `Revoke` for an -absent signal turns silence into refusal and would revoke the permission on -every request not carrying that model, which is most of them. - -Read the policy for what a signal means rather than inventing a meaning. The -mapping from a permission to a TCF purpose, and which signals count as an -opt-out, are the policy's decisions so that a deployment can change them -without changing a source. - -## The models supplied - -Core supplies the models it already understood: Global Privacy Control, a GPP -sale opt-out, a US Privacy string, and TCF v2. A deployment configuring -nothing gets those, in that order. +Consulting a peer goes one level deep. A provider answering a consultation +cannot consult in turn, so two providers asking each other cannot loop. + +## Withdrawal is a separate question + +A provider may also say that the request explicitly *withdraws* a permission, +which is different from not granting it. A withdrawal of storage expires the +browser cookie and writes the authoritative tombstone against the identifier. +A permission that is merely not set strips the response headers and leaves an +already-issued identifier alone, so a returning visitor is not permanently +withdrawn before they get to answer. Most schemes have no such notion, a +browser setting and a sale opt-out included, and only TCF answers it. Core +then scopes the answer to the jurisdiction, so a refusal only withdraws where +the storage baseline did not grant storage outright, because where it did +the identifier never depended on the record. + +## Writing a provider + +Implement `PermissionSignalProvider` in a crate that depends on core. Answer +`Neutral` for a permission the provider has no opinion on, including when the +signal it reads is absent from the request. Returning `Revoke` for an absent +signal turns silence into refusal and would revoke the permission on every +request not carrying that scheme, which is most of them. + +Read the request through `SignalInput::evidence`, which offers headers, +cookies, the path and the query, so a scheme core has never heard of can read +its own signal. The decoded consent record is offered too, for the schemes +core's consent pipeline already decodes, caches against the identifier and +expires. Prefer the record where it exists, because a provider that +re-decodes the wire would skip the cached record on a returning visitor and +answer differently from every other reader of the same request. + +Read the policy for what is a deployment's decision rather than the scheme's, +such as which signals count as an opt-out and what an opt-out takes away, so +that a deployment can change those without changing a provider. + +Register the crate at the adapter's composition root, where every adapter +builds its `RuntimeServices`. The adapter lists the providers it links, in the +default order, and `build_permission_signal_providers` selects and orders them +from configuration. + +## The providers supplied + +Four crates ship, under `crates/permission-signal/`, and a deployment +configuring nothing gets all four in this order: + +| Identifier | Crate | Reads | +| ------------------ | ------------ | ------------------------------------------------- | +| `gpc` | `gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp-sale-opt-out` | `gpp` | The US sale opt-out in a GPP string | +| `us-privacy` | `us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `tcf` | A TCF v2 record, with its purpose mapping in code | The three opt-outs are separate rather than one so that a publisher who does not act on Global Privacy Control can remove it and keep the other two. -## What is not a source +## What is not a provider -A consent record that arrives and cannot be read revokes, ahead of every -configured model and whichever ones are configured. That is error handling, -not a signalling model, so it is not in the list and cannot be removed. A +A consent record that arrives and cannot be read revokes, ahead of the +providers and whichever of them are configured. That is error handling, not a +signaling scheme, so it is not in the list and cannot be removed. A publisher chooses which signals to act on; they do not choose what happens when one of those signals arrives unreadable. An unreadable record is a preference someone expressed that could not be read, which is not the same as no record at all, so it must not degrade to the no-signal baseline. It overrides rather than taking a place in the order, because the ordered rule -would otherwise let a readable record from one model overwrite the refusal +would otherwise let a readable record from one scheme overwrite the refusal caused by an unreadable one from another. diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index f8fac0af07..19686dfe63 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -2,160 +2,202 @@ use std::sync::Arc; +use error_stack::Report; + use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; +use crate::settings::Settings; -/// What a signal source may read about a request. +/// What a signal provider may read about a request. /// -/// A struct rather than a parameter list, so a source needing something new +/// A struct rather than a parameter list, so a provider needing something new /// does not change every implementation. pub struct SignalInput<'a> { /// The decoded consent record for this request. + /// + /// Offered because the schemes that ship by default already decode into + /// it, core keeps it against the Edge Cookie identifier between requests, + /// and the rest of the system reads it. A provider is not required to use + /// it, and a scheme core has never heard of will not appear in it. Such a + /// provider reads [`evidence`](Self::evidence) instead and decodes whatever + /// its scheme needs. pub consent: &'a ConsentContext, + /// The request itself, as evidence. + /// + /// This is the open half of the seam, and it is [`RequestInfo`], the same + /// abstraction the Edge Cookie and device providers already read. A + /// provider asks for the header, cookie, path or query parameter its own + /// scheme uses, so core holds no list of which evidence a scheme may read. + pub evidence: &'a dyn RequestInfo, /// The policy from `permissions.yaml`, which decides what a signal means - /// rather than leaving each source to invent its own meaning. + /// rather than leaving each provider to invent its own meaning. pub policy: &'a SignalPolicy, /// What the country and region rules say about this permission, before any - /// source is asked. A source amends this rather than deciding alone. + /// provider is asked. A provider amends this rather than deciding alone. pub baseline: Acquisition, - /// What the sources asked before this one settled on. + /// What the providers asked before this one settled on. /// /// [`ConsentSignal::Neutral`] means none of them had an opinion, so the /// baseline still stands. pub settled: ConsentSignal, - /// Every source in configured order, this one included. - sources: &'a [Arc], - /// Where in that order the source being asked sits. + /// Every provider in configured order, this one included. + providers: &'a [Arc], + /// Where in that order the provider being asked sits. position: usize, - /// Whether this source may consult a peer. + /// Whether this provider may consult a peer. /// - /// False while answering a consultation, which is what stops two sources + /// False while answering a consultation, which is what stops two providers /// that consult each other from looping. may_ask: bool, } impl<'a> SignalInput<'a> { - /// An input for a source asked on its own, outside an ordered run. + /// An input for a provider asked on its own, outside an ordered run. #[must_use] pub fn new( consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, policy: &'a SignalPolicy, baseline: Acquisition, ) -> Self { Self { consent, + evidence, policy, baseline, settled: ConsentSignal::Neutral, - sources: &[], + providers: &[], position: 0, may_ask: true, } } - /// Every source in configured order, this one included. + /// Every provider in configured order, this one included. /// - /// A source consults the list to decide whether a peer it cares about is + /// A provider consults the list to decide whether a peer it cares about is /// configured at all, and where it sits relative to this one. #[must_use] - pub fn sources(&self) -> &[Arc] { - self.sources + pub fn providers(&self) -> &[Arc] { + self.providers } - /// Where the source being asked sits in that order. + /// Where the provider being asked sits in that order. #[must_use] pub const fn position(&self) -> usize { self.position } - /// Whether a source with this identifier is configured. + /// Whether a provider with this identifier is configured. #[must_use] pub fn has(&self, id: &str) -> bool { - self.sources.iter().any(|source| source.id() == id) + self.providers.iter().any(|provider| provider.id() == id) } /// What a peer makes of `permission`, asked directly. /// /// The peer answers as if it were first, so the reply is that peer's own /// opinion rather than what the run has settled on so far. That is the - /// useful question: a source wanting to know whether the prior value came - /// from a particular peer asks that peer what it says. + /// useful question, because a provider wanting to know whether the prior + /// value came from a particular peer asks that peer what it says. /// - /// Returns `None` when no source carries the identifier, when the caller + /// Returns `None` when no provider carries the identifier, when the caller /// names itself, and when this input is itself answering a consultation. - /// A source must handle `None` rather than assume a peer is present. + /// A provider must handle `None` rather than assume a peer is present. #[must_use] pub fn ask(&self, id: &str, permission: Permission) -> Option { if !self.may_ask { return None; } - let (position, source) = self - .sources + let (position, provider) = self + .providers .iter() .enumerate() - .find(|(position, source)| source.id() == id && *position != self.position)?; + .find(|(position, provider)| provider.id() == id && *position != self.position)?; let input = Self { consent: self.consent, + evidence: self.evidence, policy: self.policy, baseline: self.baseline, settled: ConsentSignal::Neutral, - sources: self.sources, + providers: self.providers, position, may_ask: false, }; - Some(source.signal(permission, &input)) + Some(provider.signal(permission, &input)) } } -/// A source of permission signals. +/// A provider of permission signals. /// -/// An implementation answers for one signalling model. It reads the request, -/// applies whatever the policy says about its own model, and returns how it +/// An implementation answers for one signaling scheme. It reads the request, +/// applies whatever the policy says about its own scheme, and returns how it /// would amend one permission. /// /// # Contract /// -/// Answer [`ConsentSignal::Neutral`] for a permission this source has no +/// Answer [`ConsentSignal::Neutral`] for a permission this provider has no /// opinion on, including when the signal it reads is absent from the request. /// Returning [`ConsentSignal::Revoke`] for an absent signal would turn silence /// into refusal and revoke the permission on every request that did not carry -/// this model. -pub trait PermissionSignalSource: Send + Sync { +/// this scheme. +pub trait PermissionSignalProvider: Send + Sync { /// Stable identifier, used in configuration, in logs, and by a peer - /// looking this source up through [`SignalInput::ask`]. + /// looking this provider up through [`SignalInput::ask`]. fn id(&self) -> &'static str; - /// How this source would amend `permission` for this request. + /// How this provider would amend `permission` for this request. fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; + + /// Whether the request carries an explicit withdrawal of `permission` + /// under this scheme, as opposed to merely not granting it. + /// + /// The difference is destructive. A withdrawal of storage expires the + /// browser cookie and writes the authoritative tombstone against the + /// identifier, where a permission that is simply not set for this request + /// strips the response headers and leaves the identifier alone, so that a + /// returning visitor is not permanently withdrawn before they ever get to + /// answer. Most schemes have no such notion, a browser setting and a sale + /// opt-out included, and leave this at its default of `false`. Whether a + /// withdrawal is acted on at all is core's decision from the jurisdiction's + /// baseline, so a refusal only withdraws where the baseline did not grant + /// the permission outright, because where it did the permission never + /// depended on the record. + fn withdraws(&self, _permission: Permission, _input: &SignalInput<'_>) -> bool { + false + } } -/// Asks every source in order and returns what they settle on together. +/// Asks every provider in order and returns what they settle on together. /// /// See the module documentation for the layering and why the order is the /// configuration. #[must_use] -pub fn combine( - sources: &[Arc], +pub(crate) fn combine( + providers: &[Arc], permission: Permission, consent: &ConsentContext, + evidence: &dyn RequestInfo, policy: &SignalPolicy, baseline: Acquisition, ) -> ConsentSignal { let mut settled = ConsentSignal::Neutral; - for (position, source) in sources.iter().enumerate() { + for (position, provider) in providers.iter().enumerate() { let input = SignalInput { consent, + evidence, policy, baseline, settled, - sources, + providers, position, may_ask: true, }; - // Every source is asked, because a later one may amend what an earlier - // one settled. Stopping at the first answer would make the order mean - // the opposite of what it says. - match source.signal(permission, &input) { + // Every provider is asked, because a later one may amend what an + // earlier one settled. Stopping at the first answer would make the + // order mean the opposite of what it says. + match provider.signal(permission, &input) { ConsentSignal::Neutral => {} answer => settled = answer, } @@ -163,15 +205,172 @@ pub fn combine( settled } +/// Whether the request explicitly withdraws `permission`, scoped to the +/// jurisdiction's baseline for it. +/// +/// A withdrawal only counts where the baseline did not grant the permission +/// outright. Under a `requires_signal` baseline a refusal is the visitor +/// declining the very signal the permission depended on, so it is +/// destructive. Under a `granted` baseline the permission never depended on +/// the record, so the same refusal suppresses use for the request without +/// destroying anything. Any one provider answering [`withdraws`] is enough, +/// and no provider answering it, or none configured, is never a withdrawal. +/// +/// [`withdraws`]: PermissionSignalProvider::withdraws +#[must_use] +pub(crate) fn withdrawn( + providers: &[Arc], + permission: Permission, + consent: &ConsentContext, + evidence: &dyn RequestInfo, + policy: &SignalPolicy, + baseline: Acquisition, +) -> bool { + if matches!(baseline, Acquisition::Granted) { + return false; + } + providers.iter().enumerate().any(|(position, provider)| { + let input = SignalInput { + consent, + evidence, + policy, + baseline, + settled: ConsentSignal::Neutral, + providers, + position, + may_ask: true, + }; + provider.withdraws(permission, &input) + }) +} + +/// The providers a deployment named, in the order it named them, drawn from +/// the ones the build makes available. +/// +/// `None` means nothing was configured, which runs every available provider +/// in the order the adapter offered them. That is deliberate, so a publisher +/// gets every scheme the build knows about until they say otherwise and a +/// signal is never quietly ignored because someone forgot to list it. An +/// empty list is a publisher acting on no signal at all, and is accepted. +/// +/// # Errors +/// +/// A name matching no available provider, or a name given twice, is refused +/// rather than ignored, so a typo cannot silently stop a scheme being honored +/// and a scheme cannot run twice at two places in the order. +pub(crate) fn select( + available: &[Arc], + configured: Option<&[String]>, +) -> Result>, Report> { + let Some(names) = configured else { + return Ok(available.to_vec()); + }; + let mut selected = Vec::with_capacity(names.len()); + for (position, name) in names.iter().enumerate() { + if names[..position].contains(name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal provider `{name}` is named more than once in \ + [permission_signal] sources. Each provider runs once, at one place \ + in the order" + ), + })); + } + let Some(provider) = available.iter().find(|provider| provider.id() == name) else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal provider `{name}` is not available in this build. \ + Available providers are {}", + ids(available).join(", ") + ), + })); + }; + selected.push(Arc::clone(provider)); + } + Ok(selected) +} + +/// The available providers a configured list leaves out, for the startup log. +#[must_use] +pub(crate) fn omitted<'a>( + available: &'a [Arc], + configured: Option<&[String]>, +) -> Vec<&'a str> { + let Some(names) = configured else { + return Vec::new(); + }; + available + .iter() + .map(|provider| provider.id()) + .filter(|id| !names.iter().any(|name| name == id)) + .collect() +} + +/// The identifiers of `providers`, in order. +#[must_use] +pub(crate) fn ids(providers: &[Arc]) -> Vec<&str> { + providers.iter().map(|provider| provider.id()).collect() +} + +/// Selects the providers a deployment runs from the ones an adapter makes +/// available, and says so in the log once at startup. +/// +/// This is the adapter's composition point. Core supplies no provider of its +/// own, so an adapter hands in every scheme crate it links, in the order that +/// stands when configuration names none, and receives back the ordered list +/// the request path asks. The list is shared rather than owned, so handing it +/// to every request's services is one reference count and no copy. +/// +/// # Errors +/// +/// A configured name that matches nothing available, or a name given twice, +/// fails startup with a message naming what is available, so a typo cannot +/// silently stop a scheme being honored. +pub fn build_permission_signal_providers( + settings: &Settings, + available: &[Arc], +) -> Result]>, Report> { + let configured = settings.permission_signal.sources.as_deref(); + let selected = select(available, configured)?; + match configured { + None => log::info!( + "Permission signals: acting on every provider this build offers, [{}], no \ + [permission_signal] sources configured", + ids(&selected).join(", ") + ), + Some([]) => log::info!( + "Permission signals: acting on no provider, [permission_signal] sources is \ + empty, so every permission stays at its country and region baseline" + ), + Some(_) => log::info!( + "Permission signals: acting on [{}], asked in that order", + ids(&selected).join(", ") + ), + } + let left_out = omitted(available, configured); + if !left_out.is_empty() { + log::warn!( + "Permission signals: not acting on [{}], which are not in [permission_signal] \ + sources. A signal this deployment does not act on is read from the request \ + and then ignored", + left_out.join(", ") + ); + } + Ok(Arc::from(selected)) +} + #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; + use crate::evidence::OwnedRequestInfo; - /// A source that always answers the same thing, for testing the rule - /// rather than any particular model. + /// A provider that always answers the same thing, for testing the rule + /// rather than any particular scheme. struct Fixed(&'static str, ConsentSignal); - impl PermissionSignalSource for Fixed { + impl PermissionSignalProvider for Fixed { fn id(&self) -> &'static str { self.0 } @@ -181,56 +380,106 @@ mod tests { } } - /// A source that answers by consulting a peer, which is the behavior the + /// A provider that answers by consulting a peer, which is the behavior the /// peer visibility exists for. struct Consulting { id: &'static str, peer: &'static str, } - impl PermissionSignalSource for Consulting { + impl PermissionSignalProvider for Consulting { fn id(&self) -> &'static str { self.id } fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { match input.ask(self.peer, permission) { - // The peer refused, and this source takes the opposite view of - // the same request, which is the override the seam allows. + // The peer refused, and this provider takes the opposite view + // of the same request, which is the override the seam allows. Some(ConsentSignal::Revoke) => ConsentSignal::Grant, _ => ConsentSignal::Neutral, } } } - fn combined(sources: &[Arc]) -> ConsentSignal { + /// A provider that withdraws storage, for testing the scoping rule. + struct Withdrawing; + + impl PermissionSignalProvider for Withdrawing { + fn id(&self) -> &'static str { + "withdrawing" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Revoke + } + + fn withdraws(&self, permission: Permission, _input: &SignalInput<'_>) -> bool { + permission == Permission::StoreOnDevice + } + } + + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) + } + + fn fixed(id: &'static str, signal: ConsentSignal) -> Arc { + Arc::new(Fixed(id, signal)) + } + + fn combined(providers: &[Arc]) -> ConsentSignal { let consent = ConsentContext::default(); let policy = SignalPolicy::default(); combine( - sources, + providers, Permission::StoreOnDevice, &consent, + &no_evidence(), &policy, Acquisition::RequiresSignal, ) } - /// The same, for a run whose sources differ only in what they answer. + /// The same, for a run whose providers differ only in what they answer. fn combined_signals(signals: &[ConsentSignal]) -> ConsentSignal { - let sources: Vec> = signals + let providers: Vec> = signals .iter() - .map(|signal| Arc::new(Fixed("test", *signal)) as Arc) + .map(|signal| fixed("test", *signal)) .collect(); - combined(&sources) + combined(&providers) + } + + fn withdrawn_under( + providers: &[Arc], + baseline: Acquisition, + ) -> bool { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + withdrawn( + providers, + Permission::StoreOnDevice, + &consent, + &no_evidence(), + &policy, + baseline, + ) + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() } + // ------------------------------------------------------------------ + // Combining answers in order. + // ------------------------------------------------------------------ + #[test] - fn no_sources_leaves_the_place_baseline_alone() { + fn no_providers_leaves_the_place_baseline_alone() { assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); } #[test] - fn the_last_source_with_an_opinion_decides() { + fn the_last_provider_with_an_opinion_decides() { assert_eq!( combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Grant]), ConsentSignal::Grant, @@ -246,8 +495,8 @@ mod tests { #[test] fn silence_leaves_an_earlier_answer_standing() { - // The failure this guards is a later source overwriting a settled - // answer with its own absence, which would let adding a source nobody + // The failure this guards is a later provider overwriting a settled + // answer with its own absence, which would let adding a provider nobody // uses undo the one that was working. assert_eq!( combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), @@ -260,10 +509,10 @@ mod tests { } #[test] - fn silence_from_every_source_is_not_a_refusal() { - // The failure this guards is a source that reads an absent signal as a - // refusal. It would revoke the permission on every request that did not - // carry that model, which is most of them. + fn silence_from_every_provider_is_not_a_refusal() { + // The failure this guards is a provider that reads an absent signal as + // a refusal. It would revoke the permission on every request that did + // not carry that scheme, which is most of them. assert_eq!( combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), ConsentSignal::Neutral @@ -271,10 +520,10 @@ mod tests { } #[test] - fn a_source_sees_what_the_earlier_ones_settled() { + fn a_provider_sees_what_the_earlier_ones_settled() { struct Recording; - impl PermissionSignalSource for Recording { + impl PermissionSignalProvider for Recording { fn id(&self) -> &'static str { "recording" } @@ -283,10 +532,10 @@ mod tests { assert_eq!( input.settled, ConsentSignal::Revoke, - "a source is asked with the value the sources before it settled on" + "a provider is asked with the value the providers before it settled on" ); assert_eq!(input.position(), 1, "and with its own place in the order"); - assert_eq!(input.sources().len(), 2, "and with the whole list"); + assert_eq!(input.providers().len(), 2, "and with the whole list"); assert_eq!( input.baseline, Acquisition::RequiresSignal, @@ -296,61 +545,59 @@ mod tests { } } - let sources: Vec> = vec![ - Arc::new(Fixed("opt-out", ConsentSignal::Revoke)), - Arc::new(Recording), - ]; - assert_eq!(combined(&sources), ConsentSignal::Revoke); + let providers: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke), Arc::new(Recording)]; + assert_eq!(combined(&providers), ConsentSignal::Revoke); } #[test] - fn a_source_can_override_a_peer_by_consulting_it() { - // The worked example from the README: a later source overrides a + fn a_provider_can_override_a_peer_by_consulting_it() { + // The worked example from the README: a later provider overrides a // refusal because of who made it, not merely that one was made. - let sources: Vec> = vec![ - Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + let providers: Vec> = vec![ + fixed("gpc", ConsentSignal::Revoke), Arc::new(Consulting { id: "prompt", peer: "gpc", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Grant); + assert_eq!(combined(&providers), ConsentSignal::Grant); - // The same source leaves the refusal alone when it came from a peer it - // was not told to override. - let sources: Vec> = vec![ - Arc::new(Fixed("other", ConsentSignal::Revoke)), + // The same provider leaves the refusal alone when it came from a peer + // it was not told to override. + let providers: Vec> = vec![ + fixed("other", ConsentSignal::Revoke), Arc::new(Consulting { id: "prompt", peer: "gpc", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Revoke); + assert_eq!(combined(&providers), ConsentSignal::Revoke); } #[test] fn asking_reaches_a_peer_wherever_it_sits_in_the_order() { - // A source may consult one configured after it, not only before, so a - // reordering does not silently change what a source can see. - let sources: Vec> = vec![ + // A provider may consult one configured after it, not only before, so + // a reordering does not silently change what a provider can see. + let providers: Vec> = vec![ Arc::new(Consulting { id: "prompt", peer: "gpc", }), - Arc::new(Fixed("gpc", ConsentSignal::Revoke)), + fixed("gpc", ConsentSignal::Revoke), ]; assert_eq!( - combined(&sources), + combined(&providers), ConsentSignal::Revoke, "the consultation succeeded, and the later opt-out then settled it" ); } #[test] - fn asking_for_a_source_that_is_not_configured_answers_nothing() { + fn asking_for_a_provider_that_is_not_configured_answers_nothing() { struct Absent; - impl PermissionSignalSource for Absent { + impl PermissionSignalProvider for Absent { fn id(&self) -> &'static str { "absent" } @@ -358,7 +605,7 @@ mod tests { fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { assert!( input.ask("not-configured", permission).is_none(), - "a source must be able to tell a missing peer from a silent one" + "a provider must be able to tell a missing peer from a silent one" ); assert!(!input.has("not-configured")); assert!(input.has("absent"), "and can see itself in the list"); @@ -366,15 +613,15 @@ mod tests { } } - let sources: Vec> = vec![Arc::new(Absent)]; - assert_eq!(combined(&sources), ConsentSignal::Neutral); + let providers: Vec> = vec![Arc::new(Absent)]; + assert_eq!(combined(&providers), ConsentSignal::Neutral); } #[test] - fn a_source_cannot_consult_itself() { + fn a_provider_cannot_consult_itself() { struct SelfAsking; - impl PermissionSignalSource for SelfAsking { + impl PermissionSignalProvider for SelfAsking { fn id(&self) -> &'static str { "self-asking" } @@ -386,16 +633,16 @@ mod tests { } } - let sources: Vec> = vec![Arc::new(SelfAsking)]; - assert_eq!(combined(&sources), ConsentSignal::Grant); + let providers: Vec> = vec![Arc::new(SelfAsking)]; + assert_eq!(combined(&providers), ConsentSignal::Grant); } #[test] - fn two_sources_that_consult_each_other_do_not_loop() { + fn two_providers_that_consult_each_other_do_not_loop() { // Each consults the other, and the one answering a consultation is // refused a consultation of its own, so the pair settles instead of // recursing. - let sources: Vec> = vec![ + let providers: Vec> = vec![ Arc::new(Consulting { id: "first", peer: "second", @@ -405,6 +652,126 @@ mod tests { peer: "first", }), ]; - assert_eq!(combined(&sources), ConsentSignal::Neutral); + assert_eq!(combined(&providers), ConsentSignal::Neutral); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping. + // ------------------------------------------------------------------ + + #[test] + fn a_withdrawal_counts_only_where_the_baseline_did_not_grant() { + let providers: Vec> = vec![Arc::new(Withdrawing)]; + assert!( + withdrawn_under(&providers, Acquisition::RequiresSignal), + "refusing the signal the permission depended on is destructive" + ); + assert!( + withdrawn_under(&providers, Acquisition::Denied), + "and so is refusing under a baseline that never allowed it" + ); + assert!( + !withdrawn_under(&providers, Acquisition::Granted), + "where the permission never depended on the record, the refusal suppresses \ + without destroying" + ); + } + + #[test] + fn a_provider_that_merely_revokes_does_not_withdraw() { + // Revoke and withdraw are different questions. A sale opt-out revokes + // and must never destroy an identifier. + let providers: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke)]; + assert!(!withdrawn_under(&providers, Acquisition::RequiresSignal)); + } + + #[test] + fn no_providers_never_withdraw() { + assert!(!withdrawn_under(&[], Acquisition::RequiresSignal)); + } + + // ------------------------------------------------------------------ + // Selecting which providers run. + // ------------------------------------------------------------------ + + fn four() -> Vec> { + vec![ + fixed("gpc", ConsentSignal::Neutral), + fixed("gpp-sale-opt-out", ConsentSignal::Neutral), + fixed("us-privacy", ConsentSignal::Neutral), + fixed("tcf", ConsentSignal::Neutral), + ] + } + + #[test] + fn naming_nothing_runs_every_available_provider_in_the_offered_order() { + let selected = select(&four(), None).expect("should accept no configuration"); + assert_eq!( + ids(&selected), + vec!["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"], + "a publisher who configures nothing acts on every scheme the build knows, so \ + one is never ignored because they forgot to list it" + ); + } + + #[test] + fn the_configured_order_is_the_order_they_run_in() { + let reversed = names(&["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"]); + let selected = select(&four(), Some(&reversed)).expect("should accept known names"); + assert_eq!( + ids(&selected), + vec!["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"], + "the list is the order, not merely the membership" + ); + } + + #[test] + fn an_empty_list_is_acting_on_no_signal_and_is_accepted() { + let selected = select(&four(), Some(&[])).expect("should accept an empty list"); + assert!(selected.is_empty()); + assert_eq!( + omitted(&four(), Some(&[])).len(), + 4, + "and every provider is reported left out" + ); + } + + #[test] + fn a_name_matching_no_available_provider_is_refused() { + // Matched rather than `expect_err`, because the success type holds + // trait objects that are deliberately not `Debug`. + let Err(error) = select(&four(), Some(&names(&["gpc", "not-a-provider"]))) else { + panic!("should refuse a name this build does not offer"); + }; + let message = format!("{error:?}"); + assert!( + message.contains("not-a-provider") && message.contains("gpc, gpp-sale-opt-out"), + "the refusal names the bad entry and what is available: {message}" + ); + } + + #[test] + fn naming_a_provider_twice_is_refused() { + let Err(error) = select(&four(), Some(&names(&["gpc", "tcf", "gpc"]))) else { + panic!("should refuse a provider named twice"); + }; + assert!( + format!("{error:?}").contains("more than once"), + "a provider runs once, at one place in the order" + ); + } + + #[test] + fn a_left_out_provider_is_reported_as_omitted() { + let configured = names(&["gpc", "tcf"]); + assert_eq!( + omitted(&four(), Some(&configured)), + vec!["gpp-sale-opt-out", "us-privacy"] + ); + assert!( + omitted(&four(), None).is_empty(), + "configuring nothing omits nothing" + ); } } diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 1907302cee..ea6445152d 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -376,21 +376,23 @@ enum RevokeSet { Set(PermissionSet), } -/// How each session signal maps onto permissions, parsed from the `signals` -/// section of `permissions.yaml`. +/// What a deployment decides about the shipped signal schemes, parsed from the +/// `signals` section of `permissions.yaml`. /// -/// The permission model holds this as data so the consent mapping applies it -/// rather than encoding any signal policy in the code. It is jurisdiction-free: -/// it says only how a decoded signal grants or revokes each Data Use, and the +/// The permission model holds this as data so a deployment changes it without +/// changing a provider. It is jurisdiction-free, and it carries only the +/// decisions that are a deployment's to make: whether a TCF record answers at +/// all, which signals count as a US-style opt-out, and what an opt-out takes +/// away. What each scheme's own signal means, such as which TCF purpose grants +/// which Data Use, is that scheme's provider crate's, not this policy's. The /// country/region baseline decides the rest. #[derive(Debug, Clone, Default)] pub struct SignalPolicy { - /// Whether a present TCF record's grants and revokes apply. This never - /// lets a TCF record override an opt-out signal: an opt-out always - /// suppresses the Data Uses it revokes. + /// Whether a present TCF record's grants and revokes apply. Whether a + /// consenting record then stands over an opt-out, or the opt-out over it, + /// is decided by the order the providers are asked in, which is + /// `[permission_signal] sources`, not by this flag. tcf_authoritative: bool, - /// Permission bit index to the TCF purpose number that grants it. - tcf_purpose: BTreeMap, /// The signals that constitute a US-style opt-out. opt_out_sources: Vec, /// Which Data Uses a US-style opt-out revokes. @@ -404,13 +406,6 @@ impl SignalPolicy { self.tcf_authoritative } - /// The TCF purpose number that grants `permission`, or `None` when no purpose - /// maps to it. - #[must_use] - pub fn tcf_purpose(&self, permission: Permission) -> Option { - self.tcf_purpose.get(&permission.index()).copied() - } - /// The signals that constitute a US-style opt-out. #[must_use] pub fn opt_out_sources(&self) -> &[OptOutSource] { @@ -428,37 +423,12 @@ impl SignalPolicy { } } -/// Errors when a Data Use is granted by more than one TCF purpose, because the -/// grant-and-revoke rule needs a single purpose to answer for each Data Use. -fn ensure_none_signal_duplicate( - previous: Option, - data_use: &str, -) -> Result<(), PermissionsError> { - match previous { - None => Ok(()), - Some(_) => Err(PermissionsError::DuplicateTcfDataUse { - name: data_use.to_owned(), - }), - } -} - /// Builds a validated [`SignalPolicy`] from the parsed `signals` section, /// erroring when it names an unknown Data Use or revoke rule. fn build_signal_policy(spec: &SignalsSpec) -> Result { let mut policy = SignalPolicy::default(); if let Some(tcf) = &spec.tcf { policy.tcf_authoritative = tcf.authoritative; - for (purpose, data_uses) in &tcf.purposes { - for data_use in data_uses.identifiers() { - let permission = Permission::from_identifier(data_use).ok_or_else(|| { - PermissionsError::UnknownPermission { - name: data_use.clone(), - } - })?; - let previous = policy.tcf_purpose.insert(permission.index(), *purpose); - ensure_none_signal_duplicate(previous, data_use)?; - } - } } if let Some(opt_out) = &spec.us_opt_out { policy.opt_out_sources = opt_out.sources.clone(); @@ -528,7 +498,10 @@ impl PermissionMaps { /// `permissions.yaml`. The consent mapping reads this rather than encoding /// any signal policy in the code. #[must_use] - pub(crate) fn signals(&self) -> &SignalPolicy { + // Public because the permission signal provider crates live outside core + // and read the deployment's policy, in their tests and where a provider + // needs the shipped decisions rather than a policy built by hand. + pub fn signals(&self) -> &SignalPolicy { &self.signals } @@ -752,7 +725,7 @@ impl PermissionMaps { } }) .collect(); - PermissionState { set } + PermissionState::new(set) } /// The baseline permission state for a country and region with no session @@ -792,14 +765,48 @@ impl PermissionMaps { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PermissionState { set: PermissionSet, + /// Whether the request explicitly withdrew device storage, as opposed to + /// storage merely not being set. See + /// [`storage_withdrawn`](Self::storage_withdrawn). + storage_withdrawn: bool, } impl PermissionState { - /// Builds a state in which exactly the permissions in `set` are set, for - /// tests and callers that compute the set directly. + /// Builds a state in which exactly the permissions in `set` are set, and + /// nothing is withdrawn, for tests and callers that compute the set + /// directly. #[must_use] pub const fn new(set: PermissionSet) -> Self { - Self { set } + Self { + set, + storage_withdrawn: false, + } + } + + /// The same state, recording whether device storage was explicitly + /// withdrawn. Set by assembly from what the signal providers answered, + /// scoped to the jurisdiction's storage baseline. + #[must_use] + pub const fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { + Self { + storage_withdrawn, + ..self + } + } + + /// Whether the request carries an explicit signal withdrawing device + /// storage, rather than merely lacking the permission. + /// + /// The difference is destructive. A withdrawal expires the browser cookie + /// and writes the authoritative identity-graph tombstone, where a + /// permission that is simply not set strips the Edge Cookie response + /// headers and leaves an already-issued identifier alone, so a returning + /// visitor is not permanently withdrawn before they ever get to answer. + /// Which scheme can withdraw is each provider's to say, and only where the + /// jurisdiction's storage baseline did not grant storage outright. + #[must_use] + pub const fn storage_withdrawn(&self) -> bool { + self.storage_withdrawn } /// Whether a single permission is set. @@ -894,35 +901,16 @@ struct SignalsSpec { } /// The `signals.tcf` block. +/// +/// Only whether a TCF record answers for this deployment. Which purpose grants +/// which Data Use is the TCF scheme's own knowledge and lives in the TCF +/// permission signal provider crate, so this file carries no table of another +/// scheme's numbers and a deployment running no TCF configures none. #[derive(Debug, Deserialize)] struct TcfSignalSpec { /// Whether a present TCF record's grants and revokes apply. #[serde(default = "default_true")] authoritative: bool, - /// TCF purpose number to the Data Use, or list of Data Uses, it grants - /// (and revokes when the record does not consent to that purpose). - #[serde(default)] - purposes: BTreeMap, -} - -/// One Data Use, or a list of Data Uses, granted by a single TCF purpose. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum DataUseList { - /// A single Data Use identifier. - One(String), - /// A list of Data Use identifiers. - Many(Vec), -} - -impl DataUseList { - /// The Data Use identifiers this value names, in written order. - fn identifiers(&self) -> &[String] { - match self { - DataUseList::One(one) => core::slice::from_ref(one), - DataUseList::Many(many) => many, - } - } } /// The `signals.us_opt_out` block. @@ -1304,11 +1292,6 @@ pub enum PermissionsError { /// A permission flag or modification named an unknown permission. #[display("unknown permission `{name}`")] UnknownPermission { name: String }, - /// A Data Use appeared under more than one TCF purpose in `signals.tcf`. - #[display( - "Data Use `{name}` is granted by more than one TCF purpose; map each Data Use to a single purpose" - )] - DuplicateTcfDataUse { name: String }, /// An acquisition rule was not `granted`, `requires_signal`, or `denied`. #[display("unknown acquisition rule `{value}` (expected granted, requires_signal, or denied)")] UnknownAcquisition { value: String }, @@ -2238,9 +2221,6 @@ rules: signals: tcf: authoritative: true - purposes: - 1: necessary.operations.storage - 4: advertising_marketing.first_party.targeted us_opt_out: sources: [gpc] revokes: [advertising_marketing.first_party.targeted] @@ -2248,21 +2228,6 @@ signals: let maps = PermissionMaps::from_yaml(yaml).expect("should parse the signals section"); let signals = maps.signals(); assert!(signals.tcf_authoritative(), "tcf should be authoritative"); - assert_eq!( - signals.tcf_purpose(Permission::StoreOnDevice), - Some(1), - "Purpose 1 should map to device storage" - ); - assert_eq!( - signals.tcf_purpose(Permission::SelectPersonalisedAds), - Some(4), - "Purpose 4 should map to targeted advertising" - ); - assert_eq!( - signals.tcf_purpose(Permission::CreateAdsProfile), - None, - "an unmapped Data Use has no purpose" - ); assert!( signals.opt_out_revokes(Permission::SelectPersonalisedAds), "a listed Data Use is revoked by the opt-out" diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 9f5320805f..9686f80e62 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -11,6 +11,7 @@ use super::{ }; use crate::ec::provider::EdgeCookieProvider; use crate::evidence::HostSignals; +use crate::permission_signal::PermissionSignalProvider; /// Geographic information extracted from a request. /// @@ -202,6 +203,13 @@ pub struct RuntimeServices { /// path resolves the selection itself, which is what a deployment that /// selects no provider, the Axum adapter, and the core tests all do. pub(crate) resolved_ec_provider: Option>, + /// The permission signal providers this deployment runs, in the order + /// they are asked, selected at the composition root from the scheme + /// crates the adapter links. Empty when the adapter offers none, in which + /// case every permission stays at its country and region baseline. Shared, + /// so building the services for a request bumps a reference count rather + /// than copying the list, and cloning the services does the same. + pub(crate) permission_signal_providers: Arc<[Arc]>, } impl RuntimeServices { @@ -316,6 +324,12 @@ impl RuntimeServices { self.resolved_ec_provider.clone() } + /// The permission signal providers this deployment runs, in order. + #[must_use] + pub fn permission_signal_providers(&self) -> &[Arc] { + &self.permission_signal_providers + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -404,6 +418,7 @@ pub struct RuntimeServicesBuilder { client_info: Option, host_signals: Option>, resolved_ec_provider: Option>, + permission_signal_providers: Arc<[Arc]>, } impl RuntimeServicesBuilder { @@ -421,6 +436,7 @@ impl RuntimeServicesBuilder { client_info: None, host_signals: None, resolved_ec_provider: None, + permission_signal_providers: Arc::default(), } } @@ -523,6 +539,25 @@ impl RuntimeServicesBuilder { self } + /// Set the permission signal providers this deployment runs, in the order + /// they are asked. + /// + /// Optional, and empty when unset. An adapter hands in the shared list + /// [`build_permission_signal_providers`] selected from the scheme crates it + /// links, so the request path asks exactly the providers configuration + /// named, in that order, and core supplies none of its own. + /// + /// [`build_permission_signal_providers`]: + /// crate::permission_signal::build_permission_signal_providers + #[must_use] + pub fn permission_signal_providers( + mut self, + providers: Arc<[Arc]>, + ) -> Self { + self.permission_signal_providers = providers; + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -565,6 +600,7 @@ impl RuntimeServicesBuilder { .expect("should set client_info before building RuntimeServices"), host_signals: self.host_signals, resolved_ec_provider: self.resolved_ec_provider, + permission_signal_providers: self.permission_signal_providers, } } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 520225b3b8..c167fe0aa3 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -891,120 +891,38 @@ impl DeviceConfig { } } -/// Which permission signal models run, and in what order. +/// Which permission signal providers run, and in what order. /// /// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, /// `[geo]` and `[device]` selectors, which each name one provider, signals /// compose: a request can carry a TCF string and a Global Privacy Control /// header at once and both have something to say. So this names a list, and -/// the order is the policy, because the last source with an opinion decides. +/// the order is the policy, because the last provider with an opinion decides. /// /// See `crates/trusted-server-core/src/permission_signal/README.md`. #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct PermissionSignalConfig { - /// The models to run, in order, named by the identifiers in - /// [`SOURCE_IDS`](crate::ec::consent::SOURCE_IDS). + /// The providers to run, in order, named by the identifier each provider + /// crate declares, for example `gpc`, `gpp-sale-opt-out`, `us-privacy` and + /// `tcf` for the four that ship. /// - /// Absent means every model the build knows about, in the default order. - /// A publisher who does not want to act on one removes it from the list; - /// there is no separate switch, because a model that is not listed does not - /// run. An empty list runs none of them, leaving every permission at its - /// country and region baseline. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sources: Option>, -} - -impl PermissionSignalConfig { - /// The models this configuration leaves out, in the declared order. - /// - /// Empty when nothing is configured, since that runs every model. - #[must_use] - pub fn omitted_sources(&self) -> Vec<&'static str> { - let Some(names) = self.sources.as_deref() else { - return Vec::new(); - }; - crate::ec::consent::SOURCE_IDS - .iter() - .copied() - .filter(|id| !names.iter().any(|name| name.as_str() == *id)) - .collect() - } - - /// Records the selection at startup, so which signals a deployment acts on - /// can be read from its log rather than inferred from its behavior. + /// Absent means every provider the adapter offers, in the order it offers + /// them. A publisher who does not want to act on one removes it from the + /// list, and there is no separate switch, because a provider that is not + /// listed does not run. An empty list runs none of them, leaving every + /// permission at its country and region baseline. /// - /// Any model left out is warned about, not merely noted. Removing one is a - /// deliberate choice a publisher is entitled to make, so it is not a - /// refusal, but a signal arriving on a request and then being ignored is - /// worth seeing in a log when someone asks why it had no effect. - pub fn log_selection(&self) { - let Some(names) = self.sources.as_deref() else { - log::info!( - "Permission signals: acting on every model, no [permission_signal] sources \ - configured" - ); - return; - }; - - if names.is_empty() { - log::info!( - "Permission signals: acting on no model, [permission_signal] sources is \ - empty, so every permission stays at its country and region baseline" - ); - } else { - log::info!( - "Permission signals: acting on {}, asked in that order", - names.join(", ") - ); - } - - let omitted = self.omitted_sources(); - if !omitted.is_empty() { - log::warn!( - "Permission signals: not acting on {}, which are not in [permission_signal] \ - sources. A signal this deployment does not act on is read from the request \ - and then ignored", - omitted.join(", ") - ); - } - } - - /// Checks that every named model exists in this build and none is named - /// twice. + /// Which names are valid is only known where the provider crates are + /// linked, so the check that each name matches an available provider and + /// none is repeated happens at the adapter's composition root, through + /// [`build_permission_signal_providers`], and refuses startup rather than + /// silently ignoring a typo. /// - /// Run at startup, so a typo is a refusal to boot rather than a signal that - /// silently stops being honored. - /// - /// # Errors - /// - /// - [`TrustedServerError::Configuration`] if a name is unknown or repeated. - pub fn validate_selection(&self) -> Result<(), Report> { - let Some(names) = self.sources.as_deref() else { - return Ok(()); - }; - for (position, name) in names.iter().enumerate() { - if !crate::ec::consent::SOURCE_IDS.contains(&name.as_str()) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Permission signal source `{name}` is not available in this build. \ - Available sources are {}", - crate::ec::consent::SOURCE_IDS.join(", ") - ), - })); - } - if names[..position].contains(name) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Permission signal source `{name}` is named more than once in \ - [permission_signal] sources. Each source runs once, at one place \ - in the order" - ), - })); - } - } - Ok(()) - } + /// [`build_permission_signal_providers`]: + /// crate::permission_signal::build_permission_signal_providers + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, } /// Geo / IP intelligence configuration. @@ -3503,8 +3421,6 @@ impl Settings { settings.ec.validate_provider_selection()?; settings.device.validate_provider_selection()?; settings.geo.validate_provider_selection()?; - settings.permission_signal.validate_selection()?; - settings.permission_signal.log_selection(); GeoConfig::validate_permission_policy()?; settings .geo @@ -8759,18 +8675,14 @@ formats = [{{ width = 300, height = 250 }}] mod permission_signal_config_tests { use super::*; - fn config(sources: Option<&[&str]>) -> PermissionSignalConfig { - PermissionSignalConfig { - sources: sources.map(|names| names.iter().map(|name| (*name).to_owned()).collect()), - } - } + // Which names are valid is only known where the scheme crates are linked, + // so the checks that a name matches an available provider, and that none + // is repeated, live with the seam in `permission_signal::select`. What is + // tested here is the shape of the section itself. #[test] - fn no_section_is_allowed_and_means_every_model() { + fn no_section_is_allowed_and_means_every_provider() { let config = PermissionSignalConfig::default(); - config - .validate_selection() - .expect("should accept a deployment that configures nothing"); assert!( config.sources.is_none(), "absent rather than empty, because the two mean opposite things" @@ -8778,94 +8690,31 @@ mod permission_signal_config_tests { } #[test] - fn every_declared_source_is_accepted() { - config(Some(crate::ec::consent::SOURCE_IDS)) - .validate_selection() - .expect("should accept the full list the example configuration ships"); - } - - #[test] - fn an_empty_list_is_accepted_as_acting_on_no_signal() { - config(Some(&[])) - .validate_selection() - .expect("should accept a publisher who acts on no signal at all"); - } - - #[test] - fn an_unknown_source_is_refused_at_startup() { - let error = config(Some(&["gpc", "gpq"])) - .validate_selection() - .expect_err("should refuse a name no model answers to"); - let message = format!("{error:?}"); - assert!( - message.contains("gpq"), - "the message should name the typo, so it can be found: {message}" - ); - assert!( - message.contains("gpc"), - "and list what was available: {message}" - ); - } - - #[test] - fn naming_a_source_twice_is_refused() { - let error = config(Some(&["gpc", "tcf", "gpc"])) - .validate_selection() - .expect_err("should refuse a repeat, which has no meaning in an ordered list"); - assert!(format!("{error:?}").contains("gpc")); - } - - #[test] - fn only_models_a_publisher_chooses_to_act_on_are_listed() { - // A malformed consent record fails closed whatever is configured, so it - // is deliberately not among the names. It is what happens when a signal - // arrives unreadable, not a signal anyone elects to honor. - assert!( - !crate::ec::consent::SOURCE_IDS.contains(&"malformed-record"), - "error handling must not be listed as though it were a signalling model" - ); - } - - #[test] - fn configuring_nothing_omits_nothing() { - assert!( - PermissionSignalConfig::default() - .omitted_sources() - .is_empty(), - "no section runs every model, so nothing is left out to report" - ); - } - - #[test] - fn a_dropped_model_is_reported_as_omitted() { - let omitted = config(Some(&["gpc", "tcf"])).omitted_sources(); + fn the_section_round_trips_through_toml() { + let parsed: PermissionSignalConfig = + toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); assert_eq!( - omitted, - vec!["gpp-sale-opt-out", "us-privacy"], - "what the log names has to be what was actually left out" + parsed.sources.as_deref(), + Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), + "the order written is the order read, because the order is the policy" ); } #[test] - fn an_empty_list_omits_every_model() { + fn an_empty_list_is_kept_apart_from_no_list() { + let parsed: PermissionSignalConfig = + toml::from_str("sources = []").expect("should parse an empty list"); assert_eq!( - config(Some(&[])).omitted_sources().len(), - crate::ec::consent::SOURCE_IDS.len(), - "acting on no signal leaves every model out, and the log says so" + parsed.sources.as_deref(), + Some(&[][..]), + "a publisher acting on no signal at all writes an empty list, and it must \ + not read back as having written nothing" ); } #[test] - fn the_section_round_trips_through_toml() { - let parsed: PermissionSignalConfig = - toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); - assert_eq!( - parsed.sources.as_deref(), - Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), - "the order written is the order read, because the order is the policy" - ); - parsed - .validate_selection() - .expect("should accept two known sources"); + fn an_unknown_key_is_refused() { + toml::from_str::(r#"source = ["gpc"]"#) + .expect_err("should refuse a misspelled key rather than silently ignore it"); } } From cce1f45933fde04074a80cb24c16a8a4f6e74cf4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:15 +0100 Subject: [PATCH 38/82] Ship the four IAB signal schemes as crates the adapters link Global Privacy Control, the GPP sale opt-out, the US Privacy string and TCF v2 each become a crate under crates/permission-signal, implementing PermissionSignalProvider against the record core's consent pipeline already decoded, so a returning visitor's cached record and the expiry rule are honored the same way by every reader of the request. The TCF crate owns the purpose mapping in code, with a test that every identifier in the table is a real Data Use and none is granted by two purposes, and answers the storage withdrawal question. The three opt-outs stay separate so a publisher who does not act on Global Privacy Control can remove it and keep the other two. Each crate carries the maintainers declaration the vendor crates carry. Every adapter links the four, in one order, selects them once at startup so a name no crate answers to fails there rather than on the first request, and hands the shared list to every request's services. The cross-scheme behavior that only shows when multiple providers run together, being a prompt's answer applying over an opt-out, one opt-out standing when another is removed, a scheme left off the list not running, and withdrawal being TCF's alone and scoped to the place, is tested in the Axum adapter, which links all four and runs natively. Nothing in these crates is vendor-specific, and core links none of them. --- CLAUDE.md | 4 +- Cargo.lock | 44 ++ Cargo.toml | 8 + crates/permission-signal/gpc/Cargo.toml | 24 + crates/permission-signal/gpc/src/lib.rs | 154 ++++++ crates/permission-signal/gpp/Cargo.toml | 24 + crates/permission-signal/gpp/src/lib.rs | 191 +++++++ crates/permission-signal/tcf/Cargo.toml | 24 + crates/permission-signal/tcf/src/lib.rs | 302 +++++++++++ crates/permission-signal/tcf/src/mapping.rs | 172 +++++++ .../permission-signal/us-privacy/Cargo.toml | 24 + .../permission-signal/us-privacy/src/lib.rs | 193 +++++++ crates/trusted-server-adapter-axum/Cargo.toml | 4 + crates/trusted-server-adapter-axum/src/app.rs | 40 +- .../src/platform.rs | 7 + .../tests/permission_signals.rs | 476 ++++++++++++++++++ .../Cargo.toml | 4 + .../src/app.rs | 35 +- .../src/platform.rs | 7 + .../trusted-server-adapter-fastly/Cargo.toml | 4 + .../trusted-server-adapter-fastly/src/app.rs | 38 ++ crates/trusted-server-adapter-spin/Cargo.toml | 4 + crates/trusted-server-adapter-spin/src/app.rs | 35 +- .../src/platform.rs | 14 +- 24 files changed, 1823 insertions(+), 9 deletions(-) create mode 100644 crates/permission-signal/gpc/Cargo.toml create mode 100644 crates/permission-signal/gpc/src/lib.rs create mode 100644 crates/permission-signal/gpp/Cargo.toml create mode 100644 crates/permission-signal/gpp/src/lib.rs create mode 100644 crates/permission-signal/tcf/Cargo.toml create mode 100644 crates/permission-signal/tcf/src/lib.rs create mode 100644 crates/permission-signal/tcf/src/mapping.rs create mode 100644 crates/permission-signal/us-privacy/Cargo.toml create mode 100644 crates/permission-signal/us-privacy/src/lib.rs create mode 100644 crates/trusted-server-adapter-axum/tests/permission_signals.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3aa298744c..cdc6a596e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ crates/ fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) geo/ # vendor geo provider crates (host geo is injected by the adapter) + permission-signal/ # permission signal provider crates, one per scheme (gpc, gpp, tcf, us-privacy); core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -357,6 +358,7 @@ deployment selects an implementation and the core stays neutral: | Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | | Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | | Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] sources` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | Principles for adding or changing a provider: @@ -415,7 +417,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors) | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] sources` list) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/Cargo.lock b/Cargo.lock index 58f0c055fe..3039eb0dde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5369,6 +5369,10 @@ dependencies = [ "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5388,6 +5392,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "worker", ] @@ -5415,6 +5423,10 @@ dependencies = [ "trusted-server-core", "trusted-server-device-fastly", "trusted-server-geo-fastly", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "urlencoding", ] @@ -5439,6 +5451,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5601,6 +5617,34 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 3301e77773..3046f5bbae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ resolver = "2" members = [ "crates/device/fastly", "crates/geo/fastly", + "crates/permission-signal/gpc", + "crates/permission-signal/gpp", + "crates/permission-signal/tcf", + "crates/permission-signal/us-privacy", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -114,6 +118,10 @@ tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } trusted-server-device-fastly = { path = "crates/device/fastly" } trusted-server-geo-fastly = { path = "crates/geo/fastly" } +trusted-server-permission-signal-gpc = { path = "crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "crates/permission-signal/us-privacy" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/permission-signal/gpc/Cargo.toml b/crates/permission-signal/gpc/Cargo.toml new file mode 100644 index 0000000000..1e64caf2d9 --- /dev/null +++ b/crates/permission-signal/gpc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpc" +description = "Global Privacy Control as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs new file mode 100644 index 0000000000..f744dba5c1 --- /dev/null +++ b/crates/permission-signal/gpc/src/lib.rs @@ -0,0 +1,154 @@ +//! Global Privacy Control as a permission signal provider. +//! +//! Answers from the `Sec-GPC` request header, which core reads into the +//! consent record's `gpc` flag. Separate from the GPP and US Privacy providers +//! so that a publisher who does not act on Global Privacy Control can leave +//! this one out of the configured list without also losing the other two +//! opt-outs. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "gpc"; + +/// The `Sec-GPC` request header, Global Privacy Control. +#[derive(Debug, Default, Clone, Copy)] +pub struct GpcProvider; + +impl GpcProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GpcProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input.policy.opt_out_sources().contains(&OptOutSource::Gpc) { + return ConsentSignal::Neutral; + } + if input.consent.gpc && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy lists this scheme as an opt-out and revokes device + /// storage on it, and leaves ad measurement alone. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_header(set: bool) -> ConsentContext { + ConsentContext { + gpc: set, + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GpcProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(GpcProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_when_the_header_is_set() { + assert_eq!( + answer( + &with_header(true), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_header(true), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "what an opt-out takes away is the policy's decision, and measurement is not listed" + ); + } + + #[test] + fn is_silent_when_the_header_is_absent() { + assert_eq!( + answer( + &with_header(false), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + // A policy declaring no opt-out sources at all. + let unlisted = SignalPolicy::default(); + assert_eq!( + answer(&with_header(true), &unlisted, Permission::StoreOnDevice), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even when the header is set" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_header(true); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GpcProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a browser setting suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml new file mode 100644 index 0000000000..ecbcc7194d --- /dev/null +++ b/crates/permission-signal/gpp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpp" +description = "The GPP US sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs new file mode 100644 index 0000000000..af0b504d80 --- /dev/null +++ b/crates/permission-signal/gpp/src/lib.rs @@ -0,0 +1,191 @@ +//! The GPP US sale opt-out as a permission signal provider. +//! +//! Answers from the US sale opt-out carried in the `__gpp` string, which core +//! decodes into the consent record. It says nothing about the EU TCF section a +//! GPP string may also carry, because that is the TCF provider's scheme. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "gpp-sale-opt-out"; + +/// A GPP US sale opt-out, read from the `__gpp` string. +#[derive(Debug, Default, Clone, Copy)] +pub struct GppSaleOptOutProvider; + +impl GppSaleOptOutProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GppSaleOptOutProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::GppSaleOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out) + == Some(true); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_opt_out(value: Option) -> ConsentContext { + ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: value, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GppSaleOptOutProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(GppSaleOptOutProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(false)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a section present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_opt_out(None), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a section carrying no sale flag is silence" + ); + } + + #[test] + fn is_silent_when_no_gpp_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_opt_out(Some(true)); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GppSaleOptOutProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml new file mode 100644 index 0000000000..f57ca510cb --- /dev/null +++ b/crates/permission-signal/tcf/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-tcf" +description = "IAB TCF v2 as a permission signal provider, with the purpose mapping." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs new file mode 100644 index 0000000000..21e362942d --- /dev/null +++ b/crates/permission-signal/tcf/src/lib.rs @@ -0,0 +1,302 @@ +//! IAB TCF v2 as a permission signal provider. +//! +//! Answers from the decoded TCF record for the purposes this crate maps to +//! each permission, and is the one place that knows what a TCF purpose is. +//! Core decodes the TC string, keeps the record against the Edge Cookie +//! identifier, expires it by age and resolves it against a GPP EU section, and +//! this provider reads what that pipeline produced rather than decoding the +//! cookie a second time. Reading the wire directly would silently skip the +//! cached record on a returning visitor and the expiry rule, and answer +//! differently from every other reader of the same request. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +mod mapping; + +pub use mapping::purpose_for; + +use trusted_server_core::consent::effective_tcf; +#[cfg(test)] +use trusted_server_core::consent::types::TcfConsent; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "tcf"; + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is this crate's, in +/// [`purpose_for`], so core carries no table of another scheme's numbers. A +/// permission no purpose maps to gets silence, not a refusal. +#[derive(Debug, Default, Clone, Copy)] +pub struct TcfProvider; + +impl TcfProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for TcfProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy still says whether a TCF record answers for this + // deployment at all. What it no longer says is which purpose grants + // which permission, because that is this scheme's own knowledge. + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(purpose) = mapping::purpose_for(permission) else { + // TCF has nothing to say about this Data Use, so it says nothing. + return ConsentSignal::Neutral; + }; + let Some(record) = effective_tcf(input.consent) else { + // No TCF record on the request. Silence, not refusal, because + // reading an absent scheme as a refusal would revoke on every + // request that did not carry it. + return ConsentSignal::Neutral; + }; + if record.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal. Reading it + // as silence would leave the country baseline standing and grant + // what they declined. + ConsentSignal::Revoke + } + } + + /// Only a TCF record refusing storage withdraws, because only TCF records + /// a visitor declining the very signal storage depended on. A US-style + /// opt-out suppresses use for the request and never destroys an identifier, + /// so the other providers leave this at its default. + /// + /// Whether the refusal is destructive at all is core's to decide from the + /// jurisdiction's storage baseline, which is why this answers the narrow + /// question only. It does not consult `tcf_authoritative`, matching the + /// rule as it stood before the seam, where a record refusing storage + /// withdrew whether or not the policy let the record grant anything. + fn withdraws(&self, permission: Permission, input: &SignalInput<'_>) -> bool { + if permission != Permission::StoreOnDevice { + return false; + } + effective_tcf(input.consent).is_some_and(|record| !record.has_storage_consent()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy, under which a TCF record answers. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + fn with_record(consented: &[usize]) -> ConsentContext { + ConsentContext { + tcf: Some(tcf_with_purposes(consented)), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().signal(permission, &input) + } + + fn withdraws(consent: &ConsentContext, policy: &SignalPolicy, permission: Permission) -> bool { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().withdraws(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(TcfProvider::new().id(), ID); + } + + #[test] + fn grants_a_permission_whose_purpose_the_record_consents_to() { + assert_eq!( + answer( + &with_record(&[1]), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Grant, + "Purpose 1 consent grants device storage" + ); + } + + #[test] + fn revokes_a_permission_whose_purpose_the_record_refuses() { + assert_eq!( + answer( + &with_record(&[1]), + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Revoke, + "a purpose the visitor did not consent to is a refusal, not silence" + ); + } + + #[test] + fn is_silent_for_a_data_use_no_purpose_grants() { + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + answer(&with_record(&[1]), shipped_policy(), sale), + ConsentSignal::Neutral, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } + + #[test] + fn is_silent_when_no_record_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent record is silence, never a refusal" + ); + } + + #[test] + fn a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { + // The default policy declares no TCF block, so the record does not + // answer for the deployment. Withdrawal is the narrower, destructive + // question and keeps the rule it had before the seam, which did not + // consult the flag. + let silenced = SignalPolicy::default(); + assert!( + !silenced.tcf_authoritative(), + "the fixture must not be authoritative" + ); + assert_eq!( + answer( + &with_record(&[4]), + &silenced, + Permission::SelectPersonalisedAds + ), + ConsentSignal::Neutral, + "a record the policy does not let answer stays silent" + ); + assert!( + withdraws(&with_record(&[4]), &silenced, Permission::StoreOnDevice), + "but a record refusing storage still withdraws, as it did before the seam" + ); + } + + #[test] + fn withdraws_only_for_storage_and_only_when_refused() { + assert!( + withdraws( + &with_record(&[4]), + shipped_policy(), + Permission::StoreOnDevice + ), + "refusing Purpose 1 withdraws storage" + ); + assert!( + !withdraws( + &with_record(&[1]), + shipped_policy(), + Permission::StoreOnDevice + ), + "consenting to Purpose 1 is not a withdrawal" + ); + assert!( + !withdraws( + &with_record(&[]), + shipped_policy(), + Permission::SelectPersonalisedAds + ), + "no other permission is ever withdrawn, refused or not" + ); + assert!( + !withdraws( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + "and no record is never a withdrawal" + ); + } + + #[test] + fn reads_the_eu_section_of_a_gpp_string_when_there_is_no_standalone_record() { + // Core resolves a GPP string's EU TCF section as the effective record + // when no TC string arrived, and this provider reads what core resolved + // rather than the wire, so it sees that section too. + let consent = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(tcf_with_purposes(&[4])), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + assert_eq!( + answer( + &consent, + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Grant, + "the EU section's consent to Purpose 4 grants targeted advertising" + ); + assert!( + withdraws(&consent, shipped_policy(), Permission::StoreOnDevice), + "and its refusal of Purpose 1 withdraws storage" + ); + } +} diff --git a/crates/permission-signal/tcf/src/mapping.rs b/crates/permission-signal/tcf/src/mapping.rs new file mode 100644 index 0000000000..f9cdea45aa --- /dev/null +++ b/crates/permission-signal/tcf/src/mapping.rs @@ -0,0 +1,172 @@ +//! Which Data Use each TCF purpose grants. +//! +//! This table lives here, in the crate for the scheme it belongs to, rather +//! than in the policy file core reads. Core does not know what a TCF purpose +//! is, and a deployment that runs no TCF at all should not carry a table of +//! another scheme's numbers in its configuration. +//! +//! It was moved verbatim from the `signals.tcf.purposes` block of the sample +//! policy, so behavior is unchanged for a deployment that never edited that +//! block. A deployment that had edited it now changes this crate instead. +//! +//! # Where this should eventually come from +//! +//! The IAB Privacy Taxonomy is adding a `tcf` column. When that is finalized +//! it becomes the single source for this mapping and the table below is +//! replaced by reading it, rather than being maintained by hand. Until then +//! this is the authority for this crate. + +use trusted_server_core::permissions::Permission; + +/// A TCF purpose number and the Data Use identifiers it grants. +/// +/// Identifiers rather than [`Permission`] values, so the table reads the same +/// as the policy block it came from and can be checked against the taxonomy by +/// eye. +const PURPOSES: &[(u8, &[&str])] = &[ + (1, &["necessary.operations.storage"]), + ( + 2, + &[ + "advertising_marketing.first_party.contextual", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + ], + ), + (3, &["advertising_marketing.profiling"]), + ( + 4, + &[ + "advertising_marketing.first_party.targeted", + "advertising_marketing.third_party.targeted", + ], + ), + (5, &["advertising_marketing.personalize.profiling"]), + ( + 6, + &[ + "advertising_marketing.personalize.content", + "advertising_marketing.personalize.system", + "functional.personalization", + ], + ), + ( + 7, + &[ + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_viewability", + ], + ), + (8, &["analytics.ad_reporting.content_performance"]), + ( + 9, + &[ + "analytics.ad_reporting.market_research", + "analytics.ad_reporting.campaign_insights", + ], + ), + (10, &["necessary.operations.improve"]), + (11, &["select-basic-content"]), +]; + +/// The TCF purpose that grants `permission`, or `None` when no purpose does. +/// +/// A permission no purpose maps to is one TCF has nothing to say about, and +/// the provider answers silence for it rather than a refusal. +/// +/// Compared on the identifier string, so a lookup is at most twenty string +/// comparisons. Resolving each identifier back to a [`Permission`] first would +/// scan the whole taxonomy per row, and this runs for every permission on +/// every request. +#[must_use] +pub fn purpose_for(permission: Permission) -> Option { + let id = permission.as_str(); + PURPOSES + .iter() + .find_map(|(purpose, uses)| uses.contains(&id).then_some(*purpose)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_identifier_in_the_table_is_a_real_data_use() { + // The failure this guards is a typo silently disabling a purpose. A + // name that resolves to nothing would make the purpose grant nothing, + // and no test asserting one specific mapping would notice the ones it + // does not name. + let mut unknown = Vec::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if Permission::from_identifier(id).is_none() { + unknown.push(format!("purpose {purpose}: {id}")); + } + } + } + assert!( + unknown.is_empty(), + "these Data Use identifiers are not in the taxonomy: {unknown:?}" + ); + } + + #[test] + fn no_data_use_is_granted_by_two_purposes() { + // The policy parser used to refuse this as a duplicate. With the table + // in code the check moves here, so a purpose cannot be silently + // shadowed by an earlier row. + let mut seen = std::collections::BTreeMap::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if let Some(first) = seen.insert(*id, *purpose) { + panic!("{id} is granted by purpose {first} and again by purpose {purpose}"); + } + } + } + } + + #[test] + fn the_purposes_the_policy_block_used_to_declare_still_map() { + // The two mappings the old policy parser test pinned, now pinned here. + assert_eq!( + purpose_for(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + purpose_for(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + } + + #[test] + fn a_purpose_granting_several_uses_is_found_from_each_of_them() { + // Purpose 4 grants two Data Uses, and both must resolve back to it. + let first = Permission::from_identifier("advertising_marketing.first_party.targeted") + .expect("should be a known Data Use"); + let third = Permission::from_identifier("advertising_marketing.third_party.targeted") + .expect("should be a known Data Use"); + assert_eq!( + purpose_for(first), + Some(4), + "the first-party Data Use is Purpose 4" + ); + assert_eq!(purpose_for(third), Some(4), "and so is the third-party one"); + } + + #[test] + fn a_data_use_no_purpose_grants_maps_to_nothing() { + // A sale disclosure is a Data Use the taxonomy carries and no TCF + // purpose grants. Silence rather than a refusal is the contract, and + // it starts here. + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + purpose_for(sale), + None, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } +} diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml new file mode 100644 index 0000000000..db84aadb14 --- /dev/null +++ b/crates/permission-signal/us-privacy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-us-privacy" +description = "The US Privacy string sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs new file mode 100644 index 0000000000..af4cc6c110 --- /dev/null +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -0,0 +1,193 @@ +//! The US Privacy string sale opt-out as a permission signal provider. +//! +//! Answers from the sale opt-out carried in the four character `us_privacy` +//! string, which core decodes into the consent record. Core also constructs +//! that string from a Global Privacy Control header in a US state when the +//! deployment's consent settings say to, and this provider sees the result +//! the same way, because it reads the record and not the wire. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::PrivacyFlag; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `sources`, in logs, and when a peer consults it. +pub const ID: &str = "us-privacy"; + +/// A US Privacy string sale opt-out, read from `us_privacy`. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsPrivacyProvider; + +impl UsPrivacyProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for UsPrivacyProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::UsPrivacyOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::UsPrivacy; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_flag(opt_out_sale: PrivacyFlag) -> ConsentContext { + ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + UsPrivacyProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!(UsPrivacyProvider::new().id(), ID); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::No), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a string present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::NotApplicable), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a string saying the flag does not apply is silence" + ); + } + + #[test] + fn is_silent_when_no_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_flag(PrivacyFlag::Yes); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !UsPrivacyProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d6..b28eda1bf4 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,6 +29,10 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } [dev-dependencies] axum = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 90bcac37eb..709125b029 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -50,6 +50,31 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, +} + +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] } /// Build the application state, loading settings and constructing all per-application components. @@ -92,11 +117,17 @@ fn build_state_with_settings( ensure_provider_available(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + permission_signal_providers, })) } @@ -149,7 +180,8 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx, &state.settings); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -709,6 +741,9 @@ mod tests { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + // These tests exercise the Edge Cookie provider path, and a + // request with no signal provider resolves at the place baseline. + permission_signal_providers: Arc::default(), } } @@ -728,7 +763,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_runtime_services(&ctx, &state.settings); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); let req = ctx.into_request(); let error = build_ec_context(&state, &services, &req) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 4886bd6e41..f4e9fb6ce0 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -536,6 +536,9 @@ impl PlatformHttpClient for AxumPlatformHttpClient { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { @@ -587,6 +590,10 @@ pub fn build_runtime_services( settings, Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs new file mode 100644 index 0000000000..99ec150221 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -0,0 +1,476 @@ +//! The four shipped signal providers assembled together, as a deployment +//! runs them. +//! +//! Each provider crate tests its own scheme in isolation, in its own unit +//! tests. What is tested here is what only shows when multiple providers +//! run in order through core's assembly: an answer to a prompt applying +//! over an opt-out, one opt-out standing when another is removed, a scheme +//! left off the list not running at all, and withdrawal being TCF's alone +//! and scoped to the place. This sits in the Axum adapter's tests because +//! it is the first crate that links all four, and core deliberately links +//! none. +//! +//! The consent records here are built by hand, so nothing in core's consent +//! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy +//! opt-out from a Global Privacy Control header in a US state when the consent +//! settings say to, and the `us-privacy` provider then acts on it, which is +//! why removing `gpc` from the list alone does not make that header inert. + +use std::sync::Arc; + +use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; +use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::{ + PermissionSignalProvider, build_permission_signal_providers, +}; +use trusted_server_core::permissions::{Permission, PermissionState}; +use trusted_server_core::platform::GeoInfo; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpc::GpcProvider; +use trusted_server_permission_signal_gpp::GppSaleOptOutProvider; +use trusted_server_permission_signal_tcf::TcfProvider; +use trusted_server_permission_signal_us_privacy::UsPrivacyProvider; + +/// The four providers an adapter offers, in the default order. +fn all_four() -> Vec> { + vec![ + Arc::new(GpcProvider::new()), + Arc::new(GppSaleOptOutProvider::new()), + Arc::new(UsPrivacyProvider::new()), + Arc::new(TcfProvider::new()), + ] +} + +/// The providers a deployment gets from naming these identifiers in +/// `[permission_signal] sources`, through the same entry point an adapter's +/// composition root uses. +fn configured(names: &[&str]) -> Arc<[Arc]> { + let mut settings = Settings::default(); + settings.permission_signal.sources = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + build_permission_signal_providers(&settings, &all_four()) + .expect("should select providers this build offers") +} + +/// Every provider except the one named, in the default order, as a +/// publisher removes one from configuration. +fn all_but(excluded: &str) -> Arc<[Arc]> { + let names: Vec<&str> = all_four() + .iter() + .map(|provider| provider.id()) + .filter(|id| *id != excluded) + .collect(); + configured(&names) +} + +fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::default() +} + +fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + providers: &[Arc], +) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, providers) +} + +/// Builds a minimal decoded TCF record consenting to the given 1-indexed +/// purposes, with everything else refused. +fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } +} + +fn us_privacy_opted_out() -> UsPrivacy { + UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + } +} + +fn gpp_sale_opted_out() -> GppConsent { + GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + } +} + +/// A US opt-out state, where the baseline grants storage without a signal, so +/// a revoke is observable as a drop and a refusal is never a withdrawal. +fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } +} + +// ---------------------------------------------------------------------- +// Which providers run. +// ---------------------------------------------------------------------- + +#[test] +fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline, because the map granted + // these purposes and Global Privacy Control revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); +} + +#[test] +fn a_provider_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every provider running, the header takes storage away" + ); + + let pruned = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the provider that read the header then does not run" + ); +} + +#[test] +fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate providers rather than one. + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); +} + +#[test] +fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(UsPrivacy { + opt_out_sale: PrivacyFlag::No, + ..us_privacy_opted_out() + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out provider should suppress, whatever the others say" + ); +} + +// ---------------------------------------------------------------------- +// Opt-out and prompt precedence. +// +// The providers are asked in order and each amends what the ones before it +// settled, so a later provider can amend an opt-out. The default order asks +// Global Privacy Control first, being a browser setting with no interface of +// its own, and the schemes carrying a choice someone made through an +// interface after, which is why an answer given at a prompt amends the +// header the visitor arrived with. A deployment wanting the opposite puts +// the provider it wants to win last. +// ---------------------------------------------------------------------- + +#[test] +fn a_prompt_answer_applies_over_a_gpc_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn the_opt_out_wins_when_a_deployment_puts_it_last() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let providers = configured(&["tcf", "gpc"]); + let state = assembled(&consent, GeoStatus::Located(&geo), &providers); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the same request, with the order reversed in configuration, lets the header win" + ); +} + +// ---------------------------------------------------------------------- +// The TCF mapping, now the TCF crate's, still reaches every purpose. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // Consent to all purposes except Purpose 7 (measure ad performance), in a + // US opt-out state where the baseline granted them all, so a revoke is + // observable as a drop. + let consented: Vec = (1..=11).filter(|&purpose| purpose != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + assert!( + state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" + ); +} + +// ---------------------------------------------------------------------- +// Withdrawal scoping: only a TCF storage refusal withdraws, and only where +// the baseline did not grant storage outright. Opt-outs suppress use but +// never destroy an already-issued identifier. +// +// No location resolves at the policy's top node, the gdpr-eu group, where +// storage requires a signal. A US opt-out state grants it outright. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + state.storage_withdrawn(), + "refusing the signal storage depends on should withdraw" + ); +} + +#[test] +fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.storage_withdrawn(), + "storage never depended on the record here, so refusal suppresses without destroying" + ); +} + +#[test] +fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "a consenting record is not a withdrawal" + ); +} + +#[test] +fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_four()).storage_withdrawn() + && !assembled(&consent, GeoStatus::NoLocation, &all_four()).storage_withdrawn(), + "GPC suppresses use for the request but never destroys the identifier" + ); +} + +#[test] +fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "sale opt-outs suppress use but never destroy the identifier" + ); +} + +#[test] +fn no_signal_is_not_a_withdrawal() { + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &all_four(), + ); + assert!( + !state.storage_withdrawn(), + "absence of a signal must never destroy an identifier" + ); +} + +#[test] +fn a_withdrawal_needs_the_tcf_provider_to_be_running() { + // The withdrawal is TCF's answer, so a deployment that removed the TCF + // provider from the list has no scheme left that can withdraw. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_but("tcf")); + assert!( + !state.storage_withdrawn(), + "a scheme that does not run cannot withdraw, whatever the request carries" + ); +} + +// ---------------------------------------------------------------------- +// Unreadable and expired records, assembled with the real providers. +// ---------------------------------------------------------------------- + +#[test] +fn a_malformed_tcf_record_blocks_baseline_grants() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + assert!( + !state.storage_withdrawn(), + "and it fails closed by suppression, never destructively" + ); +} + +#[test] +fn a_readable_tcf_record_does_not_cure_an_unreadable_gpp_string() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "one scheme arriving unreadable is not cured by another scheme arriving readable" + ); +} + +#[test] +fn an_expired_tcf_record_is_not_treated_as_malformed() { + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012d..0bea7d5f7f 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -30,6 +30,10 @@ futures = { workspace = true } log = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } worker = { workspace = true, optional = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index fad5e65a4c..2da4f4f24f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -67,6 +67,12 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -135,15 +141,40 @@ fn build_state_with_settings( let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -153,7 +184,7 @@ fn build_state_with_settings( /// `[ec] provider` a second time. Nothing is carried when the composition root /// found nothing safe to keep, and the request path resolves for itself. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx, &state.settings) + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) .with_resolved_ec_provider(state.ec_provider.clone()) } @@ -721,7 +752,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services(&ctx, &settings, &Arc::default()); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 7bdeefe30d..5bcf2b855a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -601,6 +601,9 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -647,6 +650,10 @@ pub fn build_runtime_services( .backend(Arc::new(NoopBackend)) .http_client(http_client) .geo(geo) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index d0a43e6703..d7f2c2fb36 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -29,6 +29,10 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-device-fastly = { workspace = true } trusted-server-geo-fastly = { workspace = true } url = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 2445ca467d..40c28bb0a7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -176,6 +176,12 @@ pub(crate) struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. pub(crate) ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + pub(crate) permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -229,6 +235,11 @@ pub(crate) fn build_state_from_settings( let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -240,9 +251,29 @@ pub(crate) fn build_state_from_settings( default_kv_store, auction_telemetry_sink, ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + fn warn_if_certificate_check_disabled(settings: &Settings) { if !settings.proxy.certificate_check { log::warn!( @@ -341,6 +372,10 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(&state.permission_signal_providers)) .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); // Hand every request the provider resolved at the composition root, so the @@ -1646,6 +1681,9 @@ mod tests { registry: Arc::new(registry), default_kv_store, ec_provider, + // These tests exercise routing, and a request with no signal + // provider resolves at the place baseline. + permission_signal_providers: Arc::default(), }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 77c4139bc8..a0a5887a21 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,6 +32,10 @@ futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 5ce3fb3003..22d648aec5 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -63,6 +63,12 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, + /// The permission signal providers `[permission_signal] sources` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -107,15 +113,40 @@ fn build_state_with_settings( let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -541,7 +572,7 @@ impl TrustedServerApp { /// composition root already resolved so the request path does not resolve /// `[ec] provider` a second time. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx, &state.settings) + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) .with_resolved_ec_provider(state.ec_provider.clone()) } @@ -1005,7 +1036,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services(&ctx, &settings, &Arc::default()); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 642c82b876..3d00f11d65 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -763,6 +763,9 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -799,6 +802,10 @@ pub fn build_runtime_services( settings, Arc::new(NullGeo), )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1048,8 +1055,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = - build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); + let services = build_runtime_services( + &ctx, + &trusted_server_core::settings::Settings::default(), + &Arc::default(), + ); assert!( services.client_info().client_ip.is_none(), From 48919be5a8ee5d4b5db6dfcdea66be19eefe6109 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 06:02:16 +0100 Subject: [PATCH 39/82] Document permission signals and the provider order A guide page for the seam and the four shipped providers, in the site's sidebar, covering why the providers are not in core, how a request resolves, that the order is the policy and what the default order does, withdrawal as a separate question, and how a scheme is added. The permission model guide is corrected where it still described the TCF mapping as living in permissions.yaml or an opt-out as always winning over a consenting TCF record, and the example configuration now calls the entries providers and says the TCF purpose mapping is in the crate. --- docs/.vitepress/config.mts | 1 + docs/guide/permission-model.md | 62 ++++++++---- docs/guide/permission-signals.md | 166 +++++++++++++++++++++++++++++++ trusted-server.example.toml | 37 +++---- 4 files changed, 227 insertions(+), 39 deletions(-) create mode 100644 docs/guide/permission-signals.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 99c60cdba7..80f483de68 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -80,6 +80,7 @@ export default withMermaid( { text: 'Edge Cookies', link: '/guide/edge-cookies' }, { text: 'EC Setup Guide', link: '/guide/ec-setup-guide' }, { text: 'Permission Model', link: '/guide/permission-model' }, + { text: 'Permission Signals', link: '/guide/permission-signals' }, { text: 'GDPR Compliance', link: '/guide/gdpr-compliance' }, { text: 'Ad Serving', link: '/guide/ad-serving' }, { diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 9f2b433c66..9355ad62c4 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -33,9 +33,11 @@ onto them where no Data Use exists yet. That matters for reading the rest of this guide. When a provider declares the permissions its data use requires, it is naming a Data Use from that taxonomy, so an operator or an auditor can check the declaration against the taxonomy -rather than against our interpretation of it. The mapping from TCF purposes is -recorded in `permissions.yaml` alongside the rules, so no signal-to-permission -policy is hidden in code. +rather than against our interpretation of it. What a deployment decides about a +signal, whether a TCF record answers and what an opt-out takes away, is recorded +in `permissions.yaml` alongside the rules. What a scheme's own signal means, such +as which TCF purpose grants which Data Use, belongs to that scheme's +[permission signal provider](./permission-signals) and is tested there. ## Separating legal policy from the core @@ -93,8 +95,11 @@ is one source among many, not the basis for every permission: country is identified, or the country has no rule either, the baseline at the top of the rules tree applies. That top baseline is required, so there is always one. -- **Consent signals.** TCF, GPP, or GPC decoded from the request, mapped onto - permissions as a grant or a revoke on top of the baseline. +- **Consent and privacy signals.** TCF, GPP, GPC or a US Privacy string read + from the request, mapped onto permissions as a grant or a revoke on top of + the baseline. Each is answered by a [permission signal + provider](./permission-signals), a crate outside the core, asked in the + order configuration gives. - **Interaction with the user.** A publisher may establish a preference because it chooses to, not only because a law requires it. - **Data from another source.** For example a browser extension, or a person's @@ -122,15 +127,20 @@ Europe purposes and used **only** as technical identifiers. No CMP or TCF policy is implemented in the core. Two purposes have no Data Use yet. Purpose 1 (device storage) uses a proposed `necessary.operations.storage` key, and purpose 11 keeps its TCF identifier `select-basic-content`. Both are flagged for an upstream -taxonomy addition. All eleven purposes are now resolved against the incoming +taxonomy addition. All eleven purposes are resolved against the incoming consent and privacy signals. A present TCF record grants or revokes each purpose directly, and a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy -opt-out) revokes whether or not a TCF record is present. The remaining taxonomy Data Uses +opt-out) revokes the Data Uses the policy lists, each answered by its own +provider in the order configuration gives, so which of them stands when they +disagree is that order. The remaining taxonomy Data Uses have no TCF purpose, so no signal maps to them and their configured baseline -stands. The mapping itself, which TCF purpose grants which Data Use and what a -US-style opt-out revokes, is declared in the `signals` section of -`permissions.yaml`, not in the code, so a deployer changes policy by editing that -file. +stands. What a US-style opt-out revokes, and whether a TCF record answers for +the deployment at all, are declared in the `signals` section of +`permissions.yaml`, so a deployer changes that policy by editing the file. +Which TCF purpose grants which Data Use is not policy but the TCF scheme's own +meaning, so it lives in the TCF [permission signal +provider](./permission-signals) crate, and the core carries no table of another +scheme's numbers. `permissions.yaml` carries a policy flag for **every** Data Use in the taxonomy, not only the eleven below. The eleven have a dedicated identifier because a @@ -139,7 +149,8 @@ where no informed policy decision has been made, is `denied` by default. Trusted Server is not the policy authority, so a deployer sets the flags to match its own jurisdiction rules. -The eleven named Data Uses, with the TCF purpose each maps from: +The eleven named Data Uses, with the TCF purpose each maps from, as the TCF +provider crate maps them: | # | Data Use identifier | IAB TCF Europe purpose | | --- | ----------------------------------------------- | ----------------------------------------------- | @@ -337,16 +348,23 @@ unset otherwise. The Edge Cookie provider runs only when every permission it requires is set, which is the one place a declaration currently decides whether a provider runs. -Signal precedence is fixed in code, most restrictive first. A US-style opt-out -(GPC, a GPP sale opt-out, or a US Privacy opt-out) suppresses the Data Uses -the policy revokes even when a TCF record consents, because an explicit -opt-out is never overridden by another signal. A consent record that is -present but cannot be decoded blocks baseline grants (fail-closed) rather -than degrading to the no-signal baseline. Only then does a TCF record decide -the Data Uses its purposes map to. Opt-outs suppress use for the request; -they never destroy an already-issued identifier. Destructive withdrawal (the -cookie expired and the identity-graph row tombstoned) happens only when a TCF -record refuses storage in a jurisdiction whose baseline did not grant it. +A consent record that is present but cannot be decoded blocks baseline grants +(fail-closed) rather than degrading to the no-signal baseline, ahead of every +signal provider and whichever are configured. The providers are then asked in +the order `[permission_signal] sources` gives, each amending what the ones +before it settled, and the last with an opinion decides. So which of a US-style +opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) and a consenting TCF +record stands when they disagree is the configured order, not a rule in code. +The default order asks Global Privacy Control first, because it is a browser +setting with no interface of its own, and the schemes carrying a choice +someone made through an interface after, so an answer given at a prompt +amends the header the visitor arrived with, and a deployment wanting the +opposite puts the provider it wants to win last. See +[Permission Signals](./permission-signals). Opt-outs suppress use for the +request and never destroy an already-issued identifier. Destructive +withdrawal (the cookie expired and the identity-graph row tombstoned) happens +only when a TCF record refuses storage in a jurisdiction whose baseline did not +grant it. ```mermaid flowchart TD diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md new file mode 100644 index 0000000000..36a8f6af15 --- /dev/null +++ b/docs/guide/permission-signals.md @@ -0,0 +1,166 @@ +# Permission Signals + +The [permission model](./permission-model) decides whether a permission is +set. This page is about where the signals that amend it come from, and why +none of them is built into the core. + +## Providers, not a built-in list + +A permission signal provider answers for one scheme. Global Privacy Control is +one, the sale opt-out carried in a GPP string is another, a US Privacy string +a third, and a TCF v2 record a fourth. Each is a crate that depends on the +core, implements one trait, and is linked by the adapter that runs it. The +core holds the trait, the order the providers are asked in, the country and +region baseline, and the policy vocabulary for what a deployment decides about +the shipped schemes, being whether a TCF record answers, which signals count as +a US-style opt-out and what an opt-out takes away. It holds no scheme's wire +format and no scheme's meaning. + +That is deliberate. Privacy is a non-price factor of competition. Publishers, +browsers and standards bodies compete on it, and schemes come and go. +Compiling a closed list of schemes into the core would settle that competition +in code, because the schemes built in would be the only ones a deployment +could act on, and the core maintainers would be deciding which privacy schemes +exist. So the four that ship are crates like any other, none of them +privileged by being the one that happens to be built in, and a scheme the core +has never heard of plugs in the same way. + +The core also does not know what a TCF purpose is. The mapping from TCF +purpose to Data Use lives in the TCF provider crate, so a deployment that runs +no TCF carries no table of another scheme's numbers. The purposes are the IAB +TCF Europe purposes and what they grant are IAB Tech Lab Privacy Taxonomy Data +Uses, so a reader checks the mapping against the industry's own documents +rather than against us, and the [permission model](./permission-model) guide +lists it in full. Two purposes have no Data Use yet, so the crate carries a +proposed key for one and the TCF identifier for the other until the taxonomy +adds them. + +A scheme that is not one of the four is added the same way, as a crate that +reads its own signal from the request, without a change to the core. The next one +is Model Terms for Marketing (MTM), where a publisher and the parties it passes +data to agree to be bound by a published set of terms, and what a provider +reads is whether that agreement covers this request. MTM arrives in a following +pull request, so the four here are a starting set and not the list. + +## How a request resolves + +Permissions are resolved in layers, each amending the one before. + +```mermaid +flowchart TD + B["Country and region rules
(the baseline: granted, requires signal, denied)"] --> P1 + P1["Provider 1, in configured order
may amend"] --> P2 + P2["Provider 2
may amend"] --> PN + PN["..."] --> S["The permission state for this request"] +``` + +The baseline comes from `permissions.yaml`, keyed by country and region, with +the top node of the rules tree standing in for a request whose place is +unknown. The geo provider supplies the place. No geo provider means no country, +so every request resolves at that top node, and only a lookup that failed +resolves at the requires-signal floor, because a place that could not be +determined must not be treated as the declared default. + +The providers are then asked in order. Each sees what the providers before it +settled on and may amend it. A provider with no opinion answers neutral and +leaves the prior value standing, which is different from refusing. + +## The order is the policy + +The last provider with an opinion decides, so the order is the policy. It is a +deployment's to set, not the code's to assume. + +```toml +[permission_signal] +sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +``` + +A provider not on the list does not run, and there is no separate switch. A +publisher who does not want to act on Global Privacy Control removes `"gpc"` +from the list, and the provider that reads the header then does not run. One +caveat: the core's consent pipeline can also synthesize a US Privacy opt-out +from that header for a visitor in a US state, when the consent settings say to, +which they do by default, and the `us-privacy` provider then acts on the record +it produced. A publisher who wants the header to have no effect at all turns +that setting off as well. Leaving the section out entirely runs every provider +the adapter offers, in the order it offers them, so a signal is never quietly +ignored because someone forgot to list it. An empty list runs none of them, +which is a publisher acting on no signal at all, and leaves every permission +at its country and region baseline. + +A name matching no provider the adapter links, or a name given twice, is +refused at startup rather than ignored, so a typo cannot silently stop a +scheme being honored. What ran, and what was left out, is written to the log +once at startup. + +The default order asks the signal with no interface of its own first and the +ones carrying a choice made through an interface after. Global Privacy +Control is a browser setting, so it revokes personalization on arrival, +whereas a GPP sale opt-out, a US Privacy string and a TCF record each carry +an answer a person gave, so they are asked later and amend it. A deployment +wanting the browser setting to stand over a later answer puts `gpc` last. +Trusted Server takes no view on which scheme should win, because that is a +question about a jurisdiction and a publisher. + +## The providers that ship + +| Identifier | Crate | Reads | +| ------------------ | ------------------------------------- | ------------------------------------------------- | +| `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp-sale-opt-out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | +| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | + +The three opt-outs are separate so that a publisher who does not act on Global +Privacy Control can remove it and keep the other two. What an opt-out takes +away, and whether a TCF record answers for the deployment at all, remain the +policy's decisions in the `signals` section of `permissions.yaml`, so a +deployment changes those without changing a provider. + +## Withdrawal is a separate question + +A provider may also say that the request explicitly withdraws a permission, +which is different from not granting it. A withdrawal of storage expires the +browser cookie and writes the authoritative tombstone against the identifier. +A permission that is merely not set strips the response headers and leaves an +already issued identifier alone, so a returning visitor is not permanently +withdrawn before they get to answer. + +Most schemes have no such notion, a browser setting and a sale opt-out +included, and of the four that ship only TCF answers it. The core then scopes +the answer to the jurisdiction, so a refusal only withdraws where the storage +baseline did not grant storage outright, because where it did the identifier +never depended on the record. + +## What is not a provider + +A consent record that arrives and cannot be read revokes, ahead of the +providers and whichever of them are configured. That is error handling rather +than a signaling scheme, so it is not in the list and cannot be removed. A +publisher chooses which signals to act on, but not what happens when one of +those signals arrives unreadable. An unreadable record is a preference +someone expressed that could not be read, which is not the same as no +record at all, so it must not degrade to the no-signal baseline. + +## Adding a scheme + +1. Create a crate that depends on `trusted-server-core` and implements + `PermissionSignalProvider` from `trusted_server_core::permission_signal`. + Give it a stable identifier, which is the name configuration uses. +2. Answer neutral for a permission the scheme has no opinion on, including + when its signal is absent from the request. Reading an absent signal as a + refusal would revoke the permission on every request that did not carry + the scheme, which is most of them. +3. Read the request through the evidence the seam offers, which is the + headers, cookies, path and query, so the core keeps no list of what a + scheme may read. The decoded consent record is offered too, for the + schemes the consent pipeline already decodes, caches against the + identifier and expires, and a provider for one of those should read the + record rather than the wire, so it answers the same as every other reader + of the same request. +4. Link the crate at the adapter's composition root, in the list of providers + it offers, and give it a place in the default order. + +The trait, the input a provider receives, and the rules for consulting a peer +are documented in the module itself, at +`crates/trusted-server-core/src/permission_signal/README.md`. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e50e94b400..b2fcda84e5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -203,41 +203,44 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 -# Which permission signal models run, and in what order. +# Which permission signal providers run, and in what order. # # Signals compose rather than select. A request can carry a TCF string and a # Global Privacy Control header at once, and both have something to say, so -# this is a list where [ec], [geo] and [device] each name one provider. The -# order is the policy, because the last source with an opinion decides. +# this is a list, whereas [ec], [geo] and [device] each name one provider. The +# order is the policy, because the last provider with an opinion decides. # -# Listed below is every model this build knows about, which is also exactly -# what runs when this section is absent. Remove the ones this publisher does -# not want to act on. A model that is not on the list does not run, and there -# is no separate switch to turn one off. An unknown or repeated name is -# refused at startup rather than quietly ignored. +# Listed below is every provider the shipped adapters link, which is also +# exactly what runs when this section is absent. Each is a crate under +# crates/permission-signal, outside the core. Remove the ones this publisher +# does not want to act on. A provider that is not on the list does not run, +# and there is no separate switch to turn one off. An unknown or repeated name +# is refused at startup rather than quietly ignored. # # [permission_signal] # sources = [ # "gpc", # the Sec-GPC request header, Global Privacy Control # "gpp-sale-opt-out", # a GPP US sale opt-out # "us-privacy", # a US Privacy string sale opt-out -# "tcf", # TCF v2 +# "tcf", # TCF v2, with its purpose mapping in the crate # ] # # The three opt-outs are separate entries so that a publisher who does not act # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. # -# A consent record that arrives and cannot be read revokes, whichever models -# are configured. That is error handling rather than a model to choose, so it -# is not in this list and cannot be removed. +# A consent record that arrives and cannot be read revokes, whichever providers +# are configured. That is error handling rather than a provider to choose, so +# it is not in this list and cannot be removed. # -# The default order asks the signals needing no interaction before the ones -# that follow a prompt, so a visitor who arrives with an opt-out and then -# answers a prompt has their answer applied. Reorder the list to change that. +# The default order asks Global Privacy Control first, being a browser setting +# with no interface of its own, and the three that carry a choice someone made +# through an interface after, so an answer given at a prompt amends the header +# the visitor arrived with. Reorder the list to change that. # -# The layering and what a model may consult are documented in -# crates/trusted-server-core/src/permission_signal/README.md. +# The layering, what a provider may consult, and how to add a scheme are +# documented in crates/trusted-server-core/src/permission_signal/README.md +# and in the Permission Signals guide. # Proxy behavior and first-party asset routing. Kept active with defaults. [proxy] From b752d960378e58d670859f375cda1e7327c1702f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 10 Sep 2026 08:57:17 +0100 Subject: [PATCH 40/82] Carry the terms the data is available under A permission says what may be done with the data. It does not say on what basis, and a recipient offered data needs both, because it has to decide whether the terms are ones it accepts and whether it may pass the data on. So a provider may now declare the terms documents that cover the request, core carries what every configured provider declared on the permission state, and the page reads them as `tdls` beside `set`. A locator is the address of a published document a person can read. The document must never be edited once published, which is why a version belongs in its address, because a document that can be rewritten tomorrow means a recipient can never prove what it agreed to and one edit silently rewrites the basis of every transaction already sent under it. That is a property of how a document is published rather than of its address, so the type refuses only an address nothing could fetch, and says why in its own documentation. The name matches the `tdl` member the Data Labels work puts on a node of an OpenRTB request, which is where these travel when a bid request carries them. None of the four schemes here declares terms, so the list is empty in every shipped path and the tests use a provider that declares one, through the production assembly rather than beside it. Model Terms for Marketing (MTM) is the first scheme that will declare terms and one of many rather than the only one, since a publisher, a trade body or a regulator can each publish terms and each set becomes a provider. An empty list says no terms were declared, which is not the same as terms permitting anything, so a recipient needing a basis and finding none has none. That is the reason the state carries the list at all rather than leaving a reader to assume. The permission state stops being `Copy`, because it now owns a list whose length varies. One test helper copied it and now clones it; nothing else in the workspace was relying on the copy. --- crates/trusted-server-core/src/ec/consent.rs | 59 +++++- .../src/integrations/registry.rs | 6 +- crates/trusted-server-core/src/lib.rs | 1 + .../src/permission_signal/README.md | 28 ++- .../src/permission_signal/mod.rs | 134 ++++++++++++ crates/trusted-server-core/src/permissions.rs | 85 ++++++-- crates/trusted-server-core/src/tdl.rs | 191 ++++++++++++++++++ .../lib/src/core/permissions.ts | 21 +- .../trusted-server-js/lib/src/core/types.ts | 8 + .../lib/test/core/permissions.test.ts | 56 ++++- docs/guide/permission-model.md | 4 +- docs/guide/permission-signals.md | 33 ++- 12 files changed, 591 insertions(+), 35 deletions(-) create mode 100644 crates/trusted-server-core/src/tdl.rs diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index e0a6ee4e03..67028d86f2 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -99,7 +99,9 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { /// empty slice runs none of them, which leaves every permission at its /// country and region baseline. The same providers answer whether storage /// was explicitly withdrawn, recorded on the state and read through -/// [`PermissionState::storage_withdrawn`]. +/// [`PermissionState::storage_withdrawn`], and declare the terms documents the +/// request's data is available under, read through +/// [`PermissionState::tdls`]. #[must_use] pub fn assemble_permissions( consent: &ConsentContext, @@ -128,7 +130,9 @@ pub fn assemble_permissions( maps.signals(), storage_acquisition(geo), ); - state.with_storage_withdrawn(withdrawn) + state + .with_storage_withdrawn(withdrawn) + .with_tdls(permission_signal::tdls(providers, consent, evidence)) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -224,6 +228,32 @@ mod tests { } } + /// A provider that declares a terms document, standing in for a terms + /// scheme such as Model Terms for Marketing, which is the next provider + /// and is not one of the four that ship here. + struct DeclaringTerms; + + impl PermissionSignalProvider for DeclaringTerms { + fn id(&self) -> &'static str { + "declaring-terms" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls( + &self, + _consent: &ConsentContext, + _evidence: &dyn crate::evidence::RequestInfo, + ) -> Vec { + vec![ + crate::tdl::Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ] + } + } + fn no_evidence() -> OwnedRequestInfo { OwnedRequestInfo::new(String::new(), HeaderMap::new()) } @@ -253,6 +283,31 @@ mod tests { } } + #[test] + fn a_provider_declaring_terms_reaches_the_assembled_state() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![Arc::new(DeclaringTerms)]; + let state = + assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &providers); + let addresses: Vec<&str> = state.tdls().iter().map(crate::tdl::Tdl::as_str).collect(); + assert_eq!( + addresses, + vec!["https://terms.example.com/marketing/2.txt"], + "should carry the terms the provider declared through to whatever reads the state" + ); + } + + #[test] + fn a_state_assembled_from_the_shipped_kind_of_provider_declares_no_terms() { + let consent = ConsentContext::default(); + let state = + assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &granting()); + assert!( + state.tdls().is_empty(), + "should declare nothing, because a scheme carrying no terms says nothing about them" + ); + } + #[test] fn hmac_provider_is_blocked_without_a_storage_signal() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fb9a3dd911..72fcb2404e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1491,10 +1491,10 @@ mod tests { /// The permission state observed by the last invocation, or `None` when /// the filter has not run. fn seen(&self) -> Option> { - *self - .seen + self.seen .lock() .expect("should lock the recorded permission state") + .clone() } } @@ -1512,7 +1512,7 @@ mod tests { .seen .lock() .expect("should lock the recorded permission state") = - Some(input.permissions.copied()); + Some(input.permissions.cloned()); Ok(RequestFilterDecision::Continue( RequestFilterEffects::default(), )) diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 47c0d5e85b..f5dbaca698 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -72,6 +72,7 @@ pub mod settings_data; pub mod storage; pub mod streaming_processor; pub mod streaming_replacer; +pub mod tdl; pub mod test_support; pub mod tester_cookie; pub mod tsjs; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 888385777c..1bfad5f5b4 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -39,7 +39,9 @@ reads its own signal from the request, without a change to core. The next one is Model Terms for Marketing (MTM), where a publisher and the parties it passes data to agree to be bound by a published set of terms, and what a provider reads is whether that agreement covers this request. MTM arrives in a following -pull request, so the four here are a starting set and not the list. +pull request, and it is one of many terms schemes rather than the only one, +because a publisher, a trade body or a regulator can each publish terms and +each set becomes a provider. The four here are a starting set and not the list. ## The hierarchy @@ -119,6 +121,30 @@ information available. Consulting a peer goes one level deep. A provider answering a consultation cannot consult in turn, so two providers asking each other cannot loop. +## The terms the data is available under + +A provider may also declare the terms documents the request's data is available +under, through `tdls` on the trait, and core carries what every configured +provider declared on the permission state. Whoever receives the data reads them +to decide whether those are terms they accept, and whether they may pass the +data on. No declaration means no terms were declared, which is not the same as +terms permitting anything, so a recipient needing a basis and finding none has +none. + +Each entry is the address of a published document a person can read, and the +document must never be edited once published, which is why a version belongs in +its address. A document that can be rewritten tomorrow means a recipient can +never prove what it agreed to, and one edit silently rewrites the basis of every +transaction already sent under it. That is a property of how the document is +published, so the [`Tdl`](crate::tdl::Tdl) type refuses only an address nothing +could fetch. + +None of the four schemes here carries terms, so the list is empty until a terms +scheme runs, Model Terms for Marketing (MTM) being the first of many rather than +the only one. The name matches the `tdl` member the Data Labels work puts on a +node of an `OpenRTB` request, which is where these travel once a bid request +carries them. + ## Withdrawal is a separate question A provider may also say that the request explicitly *withdraws* a permission, diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 19686dfe63..0decf789a0 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -9,6 +9,7 @@ use crate::error::TrustedServerError; use crate::evidence::RequestInfo; use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; use crate::settings::Settings; +use crate::tdl::Tdl; /// What a signal provider may read about a request. /// @@ -167,6 +168,25 @@ pub trait PermissionSignalProvider: Send + Sync { fn withdraws(&self, _permission: Permission, _input: &SignalInput<'_>) -> bool { false } + + /// The terms documents this provider says the request's data is + /// available under, empty when it declares none. + /// + /// A locator tells whoever receives the data what terms cover it, so + /// they can decide whether those are terms they accept and whether they + /// may pass the data on. The four schemes that ship carry no terms of + /// their own and leave this at its default, and a provider for a terms + /// scheme returns the document that applies to this request. Model Terms + /// for Marketing (MTM) is the first such scheme and one of many rather + /// than the only one. Core does + /// not read the documents, it carries the locators, so what a document + /// says stays between the parties bound by it. + /// + /// A locator must point at a document that is never edited once + /// published, which [`Tdl`] documents and cannot enforce. + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + Vec::new() + } } /// Asks every provider in order and returns what they settle on together. @@ -244,6 +264,29 @@ pub(crate) fn withdrawn( }) } +/// The terms documents the configured providers declare for this request. +/// +/// Asked in the same order the providers answer in, so the list reads the +/// way the deployment is configured, and a document named by two providers +/// is carried once. No provider declaring anything leaves the list empty, +/// which says no terms were declared rather than that any terms apply. +#[must_use] +pub(crate) fn tdls( + providers: &[Arc], + consent: &ConsentContext, + evidence: &dyn RequestInfo, +) -> Arc<[Tdl]> { + let mut declared: Vec = Vec::new(); + for provider in providers { + for tdl in provider.tdls(consent, evidence) { + if !declared.contains(&tdl) { + declared.push(tdl); + } + } + } + Arc::from(declared) +} + /// The providers a deployment named, in the order it named them, drawn from /// the ones the build makes available. /// @@ -402,6 +445,24 @@ mod tests { } } + /// A provider that declares a terms document, which is what a scheme like + /// Model Terms for Marketing does and none of the four that ship do. + struct Declaring(&'static str, &'static str); + + impl PermissionSignalProvider for Declaring { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + vec![Tdl::new(self.1).expect("should accept the test locator")] + } + } + /// A provider that withdraws storage, for testing the scoping rule. struct Withdrawing; @@ -427,6 +488,79 @@ mod tests { Arc::new(Fixed(id, signal)) } + #[test] + fn a_provider_declaring_no_terms_leaves_the_list_empty() { + let consent = ConsentContext::default(); + let declared = tdls( + &[fixed("quiet", ConsentSignal::Grant)], + &consent, + &no_evidence(), + ); + assert!( + declared.is_empty(), + "should declare nothing, because the four shipped schemes carry no terms" + ); + } + + #[test] + fn terms_are_collected_in_the_order_the_providers_are_asked() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + fixed("quiet", ConsentSignal::Neutral), + Arc::new(Declaring("second", "https://terms.example.com/b/1.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + let addresses: Vec<&str> = declared.iter().map(Tdl::as_str).collect(); + assert_eq!( + addresses, + vec![ + "https://terms.example.com/a/1.txt", + "https://terms.example.com/b/1.txt" + ], + "should read in the configured order, so the list matches the deployment" + ); + } + + #[test] + fn one_document_named_by_two_providers_is_carried_once() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/1.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 1, + "should carry the same document once, not once per provider naming it" + ); + } + + #[test] + fn versions_of_one_document_are_both_carried() { + let consent = ConsentContext::default(); + let providers: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/2.txt")), + ]; + let declared = tdls(&providers, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 2, + "should keep both, because a recipient agreed to one version and not the other" + ); + } + + #[test] + fn no_providers_declare_nothing() { + let consent = ConsentContext::default(); + assert!( + tdls(&[], &consent, &no_evidence()).is_empty(), + "should declare nothing when no provider runs, rather than implying terms" + ); + } + fn combined(providers: &[Arc]) -> ConsentSignal { let consent = ConsentContext::default(); let policy = SignalPolicy::default(); diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index ea6445152d..b888a47fd3 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -37,12 +37,13 @@ //! reports one. use std::collections::BTreeMap; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use serde::Deserialize; use serde_yaml_ng::Value; use crate::consent::jurisdiction::Jurisdiction; +use crate::tdl::Tdl; /// A technical permission a provider may require, labeled with its IAB Privacy /// Taxonomy Data Use, or its IAB TCF Europe purpose where no Data Use exists yet. @@ -762,13 +763,17 @@ impl PermissionMaps { /// /// A provider executes only when [`all_set`](Self::all_set) of its required /// permissions returns `true`. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PermissionState { set: PermissionSet, /// Whether the request explicitly withdrew device storage, as opposed to /// storage merely not being set. See /// [`storage_withdrawn`](Self::storage_withdrawn). storage_withdrawn: bool, + /// The terms documents the data for this request is available under. See + /// [`tdls`](Self::tdls). Shared rather than owned because the state is + /// cloned along the request path and the list is the same list. + tdls: Arc<[Tdl]>, } impl PermissionState { @@ -776,10 +781,11 @@ impl PermissionState { /// nothing is withdrawn, for tests and callers that compute the set /// directly. #[must_use] - pub const fn new(set: PermissionSet) -> Self { + pub fn new(set: PermissionSet) -> Self { Self { set, storage_withdrawn: false, + tdls: Arc::default(), } } @@ -787,13 +793,33 @@ impl PermissionState { /// withdrawn. Set by assembly from what the signal providers answered, /// scoped to the jurisdiction's storage baseline. #[must_use] - pub const fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { + pub fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { Self { storage_withdrawn, ..self } } + /// The same state, carrying the terms documents the data for this request + /// is available under. Set by assembly from what the signal providers + /// declared, in the order they are asked. + #[must_use] + pub fn with_tdls(self, tdls: Arc<[Tdl]>) -> Self { + Self { tdls, ..self } + } + + /// The terms documents the data for this request is available under, in + /// the order the providers were asked. + /// + /// Whoever receives the data reads these to decide whether the terms are + /// ones they accept, and whether they may pass the data on. An empty list + /// says no terms were declared, which is not the same as terms that permit + /// anything, so a recipient that needs a basis and finds none has none. + #[must_use] + pub fn tdls(&self) -> &[Tdl] { + &self.tdls + } + /// Whether the request carries an explicit signal withdrawing device /// storage, rather than merely lacking the permission. /// @@ -834,9 +860,12 @@ impl PermissionState { /// /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the /// same state always serializes to the same bytes whatever order the set - /// was built in. An empty state - /// renders as `{"set":[]}`, which is an answer (nothing is set) rather than - /// a missing value, so page code never has to tell the two apart. + /// was built in. `tdls` carries the terms documents the data is available + /// under, in the order the providers were asked, so a page module reads the + /// terms alongside the permissions. An empty state renders as + /// `{"set":[],"tdls":[]}`, and both are answers (nothing is set, no terms + /// were declared) rather than missing values, so page code never has to + /// tell the two apart. /// /// This is the only place the page shape is spelled, so no caller writes /// the JSON by hand. @@ -853,16 +882,20 @@ impl PermissionState { /// ); /// assert_eq!( /// state.page_json(), - /// r#"{"set":["necessary.operations.storage"]}"# + /// r#"{"set":["necessary.operations.storage"],"tdls":[]}"# /// ); /// - /// assert_eq!(PermissionState::default().page_json(), r#"{"set":[]}"#); + /// assert_eq!( + /// PermissionState::default().page_json(), + /// r#"{"set":[],"tdls":[]}"# + /// ); /// ``` #[must_use] pub fn page_json(&self) -> String { let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); names.sort_unstable(); - serde_json::json!({ "set": names }).to_string() + let tdls: Vec<&str> = self.tdls.iter().map(Tdl::as_str).collect(); + serde_json::json!({ "set": names, "tdls": tdls }).to_string() } } @@ -1369,13 +1402,39 @@ mod tests { "set": [ "advertising_marketing.first_party.contextual", "necessary.operations.storage", - ] + ], + "tdls": [], }) .to_string(), "should list every set permission by Data Use name, sorted" ); } + #[test] + fn page_json_carries_the_terms_the_data_is_available_under() { + // Arrange: the state a request resolves to when a terms scheme + // declared the document its data is offered under. + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) + .with_tdls(Arc::from(vec![ + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ])); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": ["necessary.operations.storage"], + "tdls": ["https://terms.example.com/marketing/2.txt"], + }) + .to_string(), + "page code should read the terms alongside the permissions" + ); + } + #[test] fn page_json_of_an_empty_state_is_an_empty_set() { // Arrange @@ -1387,8 +1446,8 @@ mod tests { // Assert assert_eq!( json, - json!({ "set": [] }).to_string(), - "an empty state should render as an empty set, not as nothing" + json!({ "set": [], "tdls": [] }).to_string(), + "an empty state should render as an empty set and no declared terms, not as nothing" ); } diff --git a/crates/trusted-server-core/src/tdl.rs b/crates/trusted-server-core/src/tdl.rs new file mode 100644 index 0000000000..a2202cb831 --- /dev/null +++ b/crates/trusted-server-core/src/tdl.rs @@ -0,0 +1,191 @@ +//! Terms Document Locators, the labels saying what terms the data offered for +//! a request is available under. +//! +//! A locator is the address of a published document a person can read, stating +//! the basis on which the data at hand may be collected, shared and used. A +//! recipient reads the locators alongside the data, decides whether those terms +//! are ones it accepts, and decides on the same basis whether it may pass the +//! data on. No locator means no terms are declared, which a recipient must not +//! read as permission. +//! +//! Nothing here interprets a document. Core carries the locators a +//! [`PermissionSignalProvider`](crate::permission_signal::PermissionSignalProvider) +//! declares for the request and makes them visible to what consumes the data, +//! and the provider for a terms scheme decides which document applies. Model +//! Terms for Marketing (MTM) is the first such scheme to arrive and one of many +//! rather than the only one, because a publisher, a trade body or a regulator +//! can each publish terms and each set becomes a provider. +//! +//! # The document must not change +//! +//! A locator has to point at a document that is never edited once published, +//! which is why a version belongs in its address. A document that can be +//! rewritten tomorrow means a recipient can never prove what it agreed to, and +//! one edit silently rewrites the basis of every transaction already sent under +//! it. That is a property of how the document is published, so no code here can +//! check it, and it is the reason this type refuses nothing but an address that +//! could not be fetched at all. + +use core::str::FromStr; + +use error_stack::Report; +use url::Url; + +use crate::error::TrustedServerError; + +/// The address of a published terms document. +/// +/// Absolute, and `http` or `https`, because a recipient has to be able to +/// fetch and read the document. See the module documentation for why the +/// document itself must be immutable and versioned. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct Tdl(String); + +impl Tdl { + /// Builds a locator from an address. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the address is not an + /// absolute `http` or `https` URL with a host, because a recipient given + /// one of those has nothing it can fetch. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::tdl::Tdl; + /// + /// let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + /// .expect("should accept an absolute https address"); + /// assert_eq!(tdl.as_str(), "https://terms.example.com/marketing/2.txt"); + /// + /// assert!(Tdl::new("/marketing/2.txt").is_err()); + /// ``` + pub fn new(locator: &str) -> Result> { + let parsed = Url::parse(locator).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` is not an absolute URL: {error}" + ), + }) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` must be http or https, so a \ + recipient can read the document, and not `{}`", + parsed.scheme() + ), + })); + } + if parsed.host().is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("Terms document locator `{locator}` names no host"), + })); + } + Ok(Self(locator.to_owned())) + } + + /// The address, as it is carried to a recipient. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for Tdl { + type Err = Report; + + fn from_str(locator: &str) -> Result { + Self::new(locator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_https_address_is_accepted() { + let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + tdl.as_str(), + "https://terms.example.com/marketing/2.txt", + "should carry the address unchanged" + ); + } + + #[test] + fn an_absolute_http_address_is_accepted() { + assert!( + Tdl::new("http://terms.example.com/marketing/2.txt").is_ok(), + "should accept http, because a document served over http is still readable" + ); + } + + #[test] + fn a_relative_address_is_refused() { + let error = Tdl::new("/marketing/2.txt") + .expect_err("should refuse an address a recipient cannot resolve"); + assert!( + format!("{error:?}").contains("absolute"), + "should say the address is not absolute" + ); + } + + #[test] + fn another_scheme_is_refused() { + let error = + Tdl::new("mailto:terms@example.com").expect_err("should refuse a scheme with no page"); + assert!( + format!("{error:?}").contains("http or https"), + "should name the schemes a recipient can read" + ); + } + + #[test] + fn an_empty_address_is_refused() { + assert!( + Tdl::new("").is_err(), + "should refuse an empty address rather than carry it to a recipient" + ); + } + + #[test] + fn parsing_from_a_string_gives_the_same_answer() { + let parsed: Tdl = "https://terms.example.com/marketing/2.txt" + .parse() + .expect("should parse an absolute https address"); + assert_eq!( + parsed, + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"), + "should match the constructor" + ); + } + + #[test] + fn two_locators_for_the_same_document_compare_equal() { + let one = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + one, two, + "should compare equal so the same document is carried once" + ); + } + + #[test] + fn versions_of_one_document_are_different_locators() { + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let three = Tdl::new("https://terms.example.com/marketing/3.txt") + .expect("should accept an absolute https address"); + assert_ne!( + two, three, + "should distinguish versions, because a recipient agreed to one of them" + ); + } +} diff --git a/crates/trusted-server-js/lib/src/core/permissions.ts b/crates/trusted-server-js/lib/src/core/permissions.ts index 636e61af6c..19929f2e5f 100644 --- a/crates/trusted-server-js/lib/src/core/permissions.ts +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -7,6 +7,17 @@ function isSnapshot(value: unknown): value is PermissionsSnapshot { return typeof value === 'object' && value !== null; } +// Page code reads `set` and `tdls` without checking either exists, so both are +// arrays whatever arrived. The edge always sends both, and a page assigning a +// snapshot by hand, or an older edge, may not. +function normalize(snapshot: PermissionsSnapshot): PermissionsSnapshot { + return { + ...snapshot, + set: Array.isArray(snapshot.set) ? snapshot.set : [], + tdls: Array.isArray(snapshot.tdls) ? snapshot.tdls : [], + }; +} + /** * Install the `permissions` accessor and `whenPermissions()` on the API object. * @@ -20,7 +31,9 @@ export function installPermissions(api: TsjsApi): void { // A value already on the API object came from the head-open injection, so it // is the current value; otherwise page code must still read a defined value. const injected = api.permissions; - let current: PermissionsSnapshot = isSnapshot(injected) ? injected : { set: [] }; + let current: PermissionsSnapshot = isSnapshot(injected) + ? normalize(injected) + : { set: [], tdls: [] }; let settled = false; let resolvePending: (snapshot: PermissionsSnapshot) => void = () => {}; const pending = new Promise((resolve) => { @@ -38,9 +51,9 @@ export function installPermissions(api: TsjsApi): void { return current; }, set(value: PermissionsSnapshot) { - current = value; - log.debug('permissions: received', value); - settle(value); + current = isSnapshot(value) ? normalize(value) : { set: [], tdls: [] }; + log.debug('permissions: received', current); + settle(current); }, enumerable: true, configurable: true, diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 436f216170..8a9af2e775 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -370,9 +370,17 @@ export interface GptSlotHandoff { * * The names in `set` are IAB Privacy Taxonomy Data Use keys, as resolved by the * server for this request. + * + * `tdls` are the terms documents the data for this request is available under, + * as declared by the permission signal providers the deployment runs, in the + * order they were asked. Each entry addresses a published document a person can + * read. An empty list says no terms were declared, which is not the same as + * terms that permit anything, so page code that needs a basis and finds none + * has none. */ export interface PermissionsSnapshot { set: string[]; + tdls: string[]; } export interface TsjsApi { diff --git a/crates/trusted-server-js/lib/test/core/permissions.test.ts b/crates/trusted-server-js/lib/test/core/permissions.test.ts index 9a12b340a8..fe0ece6e53 100644 --- a/crates/trusted-server-js/lib/test/core/permissions.test.ts +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -21,14 +21,17 @@ describe('core/permissions', () => { }); it('keeps permissions injected before the bundle loads and resolves with them', async () => { - const injected: PermissionsSnapshot = { set: ['necessary.operations'] }; + const injected: PermissionsSnapshot = { + set: ['necessary.operations'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; window.tsjs = { permissions: injected } as TsjsApi; await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - expect(api.permissions).toEqual({ set: ['necessary.operations'] }); - await expect(api.whenPermissions!()).resolves.toEqual({ set: ['necessary.operations'] }); + expect(api.permissions).toEqual(injected); + await expect(api.whenPermissions!()).resolves.toEqual(injected); }); it('resolves on the body seam assignment and reads the value back', async () => { @@ -36,10 +39,16 @@ describe('core/permissions', () => { const api = window.tsjs as TsjsApi; const settled = api.whenPermissions!(); - api.permissions = { set: ['marketing.advertising.serving'] }; + api.permissions = { set: ['marketing.advertising.serving'], tdls: [] }; - await expect(settled).resolves.toEqual({ set: ['marketing.advertising.serving'] }); - expect(api.permissions).toEqual({ set: ['marketing.advertising.serving'] }); + await expect(settled).resolves.toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); + expect(api.permissions).toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); }); it('falls back to the empty default when no assignment arrives before DOMContentLoaded', async () => { @@ -49,7 +58,7 @@ describe('core/permissions', () => { const settled = api.whenPermissions!(); document.dispatchEvent(new Event('DOMContentLoaded')); - await expect(settled).resolves.toEqual({ set: [] }); + await expect(settled).resolves.toEqual({ set: [], tdls: [] }); }); it('resolves at once when the document has already been parsed', async () => { @@ -59,18 +68,45 @@ describe('core/permissions', () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - await expect(api.whenPermissions!()).resolves.toEqual({ set: [] }); + await expect(api.whenPermissions!()).resolves.toEqual({ set: [], tdls: [] }); }); it('returns the same resolved value from every later call', async () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - api.permissions = { set: ['analytics.reporting'] }; + api.permissions = { set: ['analytics.reporting'], tdls: [] }; const first = await api.whenPermissions!(); const second = await api.whenPermissions!(); expect(second).toBe(first); - expect(second).toEqual({ set: ['analytics.reporting'] }); + expect(second).toEqual({ set: ['analytics.reporting'], tdls: [] }); + }); + + it('gives page code both lists when the edge sent only the permissions', async () => { + // An older edge, or a page assigning a snapshot by hand, sends no terms. + // Page code still reads `tdls` without checking it exists. + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { set: ['analytics.reporting'] } as PermissionsSnapshot; + + expect(api.permissions.tdls).toEqual([]); + await expect(api.whenPermissions!()).resolves.toEqual({ + set: ['analytics.reporting'], + tdls: [], + }); + }); + + it('carries the terms the edge declared for the request', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { + set: ['necessary.operations.storage'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; + + expect(api.permissions.tdls).toEqual(['https://terms.example.com/marketing/2.txt']); }); }); diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 9355ad62c4..5a34f7aeb2 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -436,7 +436,9 @@ denied or who was classified as a bot still receives the state, empty in that case rather than missing. Because the arrival point moves, a page module must not read `tsjs.permissions` -directly at load. TSJS core defaults the value to `{ set: [] }` and exposes +directly at load. TSJS core defaults the value to `{ set: [], tdls: [] }`, where +`tdls` are the terms documents the request's data is available under as +declared by the signal providers, and exposes `tsjs.whenPermissions()`, a promise that resolves when the real value arrives, immediately in the head-first case or at the body seam, with a `DOMContentLoaded` fallback. That promise is the waiting point for a vendor page diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md index 36a8f6af15..bbfc7898cd 100644 --- a/docs/guide/permission-signals.md +++ b/docs/guide/permission-signals.md @@ -40,7 +40,9 @@ reads its own signal from the request, without a change to the core. The next on is Model Terms for Marketing (MTM), where a publisher and the parties it passes data to agree to be bound by a published set of terms, and what a provider reads is whether that agreement covers this request. MTM arrives in a following -pull request, so the four here are a starting set and not the list. +pull request, and it is one of many terms schemes rather than the only one, +because a publisher, a trade body or a regulator can each publish terms and +each set becomes a provider. The four here are a starting set and not the list. ## How a request resolves @@ -117,6 +119,35 @@ away, and whether a TCF record answers for the deployment at all, remain the policy's decisions in the `signals` section of `permissions.yaml`, so a deployment changes those without changing a provider. +## The terms the data is available under + +A provider may also declare the terms documents the request's data is available +under, and the permission state carries what every configured provider declared, +in the order they were asked. The page reads them as `tdls` alongside `set`: + +```json +{ + "set": ["necessary.operations.storage"], + "tdls": ["https://terms.example.com/marketing/2.txt"] +} +``` + +Whoever receives the data reads them to decide whether those are terms they +accept, and whether they may pass the data on. An empty list says no terms were +declared, which is not the same as terms permitting anything, so a recipient +needing a basis and finding none has none. + +Each entry is the address of a published document a person can read, and the +document must never be edited once published, which is why a version belongs in +its address. A document that can be rewritten tomorrow means a recipient can +never prove what it agreed to, and one edit silently rewrites the basis of every +transaction already sent under it. + +None of the four schemes that ship carries terms, so the list is empty until a +terms scheme runs. Model Terms for Marketing (MTM) is the first of many rather +than the only one, since a publisher, a trade body or a regulator can each +publish terms and each set becomes a provider. + ## Withdrawal is a separate question A provider may also say that the request explicitly withdraws a permission, From c43ebef22c80c7c301ed5fe9944701991db22729 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 10:17:56 +0100 Subject: [PATCH 41/82] Keep the provider rationale on the docs page The permission signal module documentation opened with the argument for keeping signaling schemes out of core. That is a position rather than a description of the code, and the same argument already stands in docs/guide/permission-signals.md, so the module documentation now points there and keeps to what core holds. --- .../src/permission_signal/README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 1bfad5f5b4..42e2d01efd 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -10,15 +10,12 @@ answer anyone gave to a question, and a jurisdiction rule is neither. Consent is one kind of signal, so the seam takes the wider name and the consent subsystem keeps the narrower one. -## Why the providers are not in core +## What core holds -Privacy is a non-price factor of competition. Publishers, browsers and -standards bodies compete on it, and schemes come and go. Compiling a closed -list of schemes into core would settle that competition in code, because -the schemes built in would be the only ones a deployment could act on, and -the core maintainers would be deciding which privacy schemes exist. +The reasons the schemes live outside core are on the docs page, +docs/guide/permission-signals.md. -So core holds the trait, the ordering, the country baseline, and the policy +Core holds the trait, the ordering, the country baseline, and the policy vocabulary for what a deployment decides about the shipped schemes, being whether a TCF record answers, which signals count as a US-style opt-out and what an opt-out takes away. It holds no scheme's wire format and no scheme's From 2eb8712d0a3a879cc52bbb9d9039a5119f468de5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 10:17:57 +0100 Subject: [PATCH 42/82] Say what each permission signal assertion checks AGENTS.md asks for a descriptive message on every assertion, and the tests added with the permission signal seam left 21 without one. Each now states the rule it holds, so a failure names what broke rather than printing two values that differ. --- crates/permission-signal/gpc/src/lib.rs | 6 +- crates/permission-signal/gpp/src/lib.rs | 6 +- crates/permission-signal/tcf/src/lib.rs | 6 +- .../permission-signal/us-privacy/src/lib.rs | 6 +- crates/trusted-server-core/src/evidence.rs | 6 +- .../src/permission_signal/mod.rs | 76 +++++++++++++++---- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index f744dba5c1..1f05fdc252 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -83,7 +83,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(GpcProvider::new().id(), ID); + assert_eq!( + GpcProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index af0b504d80..e5b402e663 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -95,7 +95,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(GppSaleOptOutProvider::new().id(), ID); + assert_eq!( + GppSaleOptOutProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 21e362942d..2229c99802 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -156,7 +156,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(TcfProvider::new().id(), ID); + assert_eq!( + TcfProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index af4cc6c110..9ef4316cbf 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -97,7 +97,11 @@ mod tests { #[test] fn answers_to_its_identifier() { - assert_eq!(UsPrivacyProvider::new().id(), ID); + assert_eq!( + UsPrivacyProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); } #[test] diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index c1aa144a76..b296f3fef8 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -297,7 +297,11 @@ mod cookie_tests { Some("1"), "spaces around the name and value are not part of either" ); - assert_eq!(info.cookie("b"), Some("2")); + assert_eq!( + info.cookie("b"), + Some("2"), + "and so are the spaces around a later pair" + ); } #[test] diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 0decf789a0..f30871fa14 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -609,7 +609,11 @@ mod tests { #[test] fn no_providers_leaves_the_place_baseline_alone() { - assert_eq!(combined_signals(&[]), ConsentSignal::Neutral); + assert_eq!( + combined_signals(&[]), + ConsentSignal::Neutral, + "with no provider configured nothing amends the place baseline" + ); } #[test] @@ -634,11 +638,13 @@ mod tests { // uses undo the one that was working. assert_eq!( combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), - ConsentSignal::Grant + ConsentSignal::Grant, + "a later provider with no opinion leaves an earlier grant standing" ); assert_eq!( combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Neutral]), - ConsentSignal::Revoke + ConsentSignal::Revoke, + "and a later silence leaves an earlier refusal standing too" ); } @@ -649,7 +655,8 @@ mod tests { // not carry that scheme, which is most of them. assert_eq!( combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), - ConsentSignal::Neutral + ConsentSignal::Neutral, + "no provider having an opinion is not a refusal" ); } @@ -681,7 +688,11 @@ mod tests { let providers: Vec> = vec![fixed("opt-out", ConsentSignal::Revoke), Arc::new(Recording)]; - assert_eq!(combined(&providers), ConsentSignal::Revoke); + assert_eq!( + combined(&providers), + ConsentSignal::Revoke, + "the recording provider has no opinion, so the opt-out stands" + ); } #[test] @@ -695,7 +706,11 @@ mod tests { peer: "gpc", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Grant); + assert_eq!( + combined(&providers), + ConsentSignal::Grant, + "a later provider overrides a refusal made by the peer it consulted" + ); // The same provider leaves the refusal alone when it came from a peer // it was not told to override. @@ -706,7 +721,11 @@ mod tests { peer: "gpc", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Revoke); + assert_eq!( + combined(&providers), + ConsentSignal::Revoke, + "and leaves a refusal from any other peer standing" + ); } #[test] @@ -741,14 +760,21 @@ mod tests { input.ask("not-configured", permission).is_none(), "a provider must be able to tell a missing peer from a silent one" ); - assert!(!input.has("not-configured")); + assert!( + !input.has("not-configured"), + "and sees that the peer is not in the list" + ); assert!(input.has("absent"), "and can see itself in the list"); ConsentSignal::Neutral } } let providers: Vec> = vec![Arc::new(Absent)]; - assert_eq!(combined(&providers), ConsentSignal::Neutral); + assert_eq!( + combined(&providers), + ConsentSignal::Neutral, + "a provider that finds its peer missing leaves the permission unsettled" + ); } #[test] @@ -762,13 +788,20 @@ mod tests { fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // Without the guard this recurses until the stack is gone. - assert!(input.ask("self-asking", permission).is_none()); + assert!( + input.ask("self-asking", permission).is_none(), + "a provider asking itself gets no answer" + ); ConsentSignal::Grant } } let providers: Vec> = vec![Arc::new(SelfAsking)]; - assert_eq!(combined(&providers), ConsentSignal::Grant); + assert_eq!( + combined(&providers), + ConsentSignal::Grant, + "a provider refused its own consultation still answers for itself" + ); } #[test] @@ -786,7 +819,11 @@ mod tests { peer: "first", }), ]; - assert_eq!(combined(&providers), ConsentSignal::Neutral); + assert_eq!( + combined(&providers), + ConsentSignal::Neutral, + "two providers consulting each other settle instead of looping" + ); } // ------------------------------------------------------------------ @@ -817,12 +854,18 @@ mod tests { // and must never destroy an identifier. let providers: Vec> = vec![fixed("opt-out", ConsentSignal::Revoke)]; - assert!(!withdrawn_under(&providers, Acquisition::RequiresSignal)); + assert!( + !withdrawn_under(&providers, Acquisition::RequiresSignal), + "a provider that only revokes never withdraws" + ); } #[test] fn no_providers_never_withdraw() { - assert!(!withdrawn_under(&[], Acquisition::RequiresSignal)); + assert!( + !withdrawn_under(&[], Acquisition::RequiresSignal), + "with no provider configured nothing can withdraw" + ); } // ------------------------------------------------------------------ @@ -863,7 +906,7 @@ mod tests { #[test] fn an_empty_list_is_acting_on_no_signal_and_is_accepted() { let selected = select(&four(), Some(&[])).expect("should accept an empty list"); - assert!(selected.is_empty()); + assert!(selected.is_empty(), "an empty list selects no provider"); assert_eq!( omitted(&four(), Some(&[])).len(), 4, @@ -901,7 +944,8 @@ mod tests { let configured = names(&["gpc", "tcf"]); assert_eq!( omitted(&four(), Some(&configured)), - vec!["gpp-sale-opt-out", "us-privacy"] + vec!["gpp-sale-opt-out", "us-privacy"], + "the providers the list leaves out are reported in the offered order" ); assert!( omitted(&four(), None).is_empty(), From 3c9f4b0c3e87f142cbbbabff8e1d573e75f47130 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:35:07 +0100 Subject: [PATCH 43/82] Correct the Windows notes on CI runners and the CLI The Windows notes said CI runs the adapter tests on both ubuntu-latest and windows-latest. No job in this branch's test workflow runs on Windows, so the sentence now says ubuntu-latest. The notes also say that trusted-server-cli does not build on a Windows host, because its dependency edgezero-adapter-fastly uses a standard library feature that is unstable on Windows, so its tests and the template cache harness, which builds it, run in WSL too. --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a72063c035..7972540e80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ cargo install viceroy --version 0.17.0 --locked --force The Rust adapter tests run natively on Windows through the cargo aliases (`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), -and CI runs these on both `ubuntu-latest` and `windows-latest`. +and CI runs them on `ubuntu-latest`. The Docker-based integration suite (`scripts/integration-tests.sh`) and the Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, @@ -165,6 +165,10 @@ inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the WSL distro with the same toolchain as `.tool-versions` (rustup + the `wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then run the scripts from a clone on the WSL native filesystem for fast builds. +The CLI crate (`trusted-server-cli`) does not build on a Windows host either, +because its dependency `edgezero-adapter-fastly` uses a standard library +feature that is unstable on Windows, so run its tests and the template cache +harness (`scripts/template-cache-local-test.sh`), which builds it, in WSL too. --- From be0ed2dd061a7d67d8c14654b540a65e2dd29fe6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:11:59 +0100 Subject: [PATCH 44/82] Acknowledge one jurisdiction in the adapter startup tests Main's configuration-driven OpenRTB auction providers (#1016) added three startup tests each to the Cloudflare and Spin adapters, and their settings set the deprecated [ec] passphrase with no geo provider. That form migrates to provider = "hmac", and on this branch GeoConfig::validate_jurisdiction_acknowledgment requires [geo] assume_single_jurisdiction = true for an Edge Cookie provider with no geo provider, so the settings failed to load and all six tests failed. The four settings blocks now set it, as the other test settings in both adapters that select an Edge Cookie provider already do. --- crates/trusted-server-adapter-cloudflare/src/app.rs | 9 +++++++++ crates/trusted-server-adapter-spin/src/app.rs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7237471ced..50a2fbc971 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -811,6 +811,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -859,6 +862,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -903,6 +909,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 488bea5312..933502bf70 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1020,6 +1020,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); From 88a7482f81cd252b8cd275ee762c069b9028cc2a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 21:27:38 +0100 Subject: [PATCH 45/82] Run the permission signal providers in the permissions inspector The inspector calls `assemble_permissions`, which this branch later gave two more arguments, the request evidence and the signal providers. Nothing caught it, because `tools/permissions-inspector/wasm` is deliberately its own workspace, so the gates that build the main workspace never build it, and the `Permissions Inspector` workflow that does is added by this branch too and so first ran on this pull request. It failed with three E0061 errors. The inspector now builds the same four providers every adapter offers, in the same default order (`gpc`, `gpp-sale-opt-out`, `us-privacy`, `tcf`), and hands them to `assemble_permissions`. Passing an empty slice would have compiled just as well and silently stopped the page acting on any signal at all, which is the one thing the page exists to show. The evidence argument is an empty `OwnedRequestInfo`. The page carries no request, only the consent signals its form collects, and each of the four providers answers from the consent record rather than from request evidence, so empty evidence changes none of their answers. The comment in `eval_json` says so, and says that a provider reading a header or a cookie would need real evidence there. The lock file picks up the four crates, and with them the edgezero v0.0.7 to v0.0.8 bump and `cssparser`, which this separate lock had not taken from the `main` merge. `build.rs` and one line of `src/lib.rs` are rustfmt output. The crate is outside the workspace, so `cargo fmt --all` never reached it and `build.rs` had never been formatted. Tests. `./scripts/build-inspector-wasm.sh` builds clean for wasm32-unknown-unknown, and `cargo fmt --check` on the crate is clean. --- tools/permissions-inspector/wasm/Cargo.lock | 37 +++++++++++++++++++-- tools/permissions-inspector/wasm/Cargo.toml | 6 ++++ tools/permissions-inspector/wasm/build.rs | 15 +++++++-- tools/permissions-inspector/wasm/src/lib.rs | 31 ++++++++++++++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock index 555b4e912f..97123760bf 100644 --- a/tools/permissions-inspector/wasm/Cargo.lock +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" dependencies = [ "anyhow", "async-compression", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" dependencies = [ "log", "proc-macro2", @@ -1322,6 +1322,10 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -2038,6 +2042,7 @@ dependencies = [ "chacha20poly1305", "chrono", "cookie", + "cssparser", "derive_more", "ed25519-dalek", "edgezero-core", @@ -2092,6 +2097,34 @@ dependencies = [ "serde_json", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml index f212c89dbf..dee34a3cfb 100644 --- a/tools/permissions-inspector/wasm/Cargo.toml +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -9,6 +9,12 @@ crate-type = ["cdylib"] [dependencies] trusted-server-core = { path = "../../../crates/trusted-server-core" } +# The inspector runs the same permission signal providers an adapter ships, +# in the same default order, so the page answers as a deployment would. +trusted-server-permission-signal-gpc = { path = "../../../crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "../../../crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "../../../crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "../../../crates/permission-signal/us-privacy" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs index 65abf8bda7..5e8f11cb32 100644 --- a/tools/permissions-inspector/wasm/build.rs +++ b/tools/permissions-inspector/wasm/build.rs @@ -30,8 +30,17 @@ fn workspace_version() -> String { fn main() { println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); - println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); - println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); - println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!( + "cargo:rustc-env=TS_CORE_COMMIT={}", + git(&["rev-parse", "--short=9", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_DATE={}", + git(&["show", "-s", "--format=%cs", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_BRANCH={}", + git(&["rev-parse", "--abbrev-ref", "HEAD"]) + ); println!("cargo:rerun-if-changed=../../../Cargo.toml"); } diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index 928df80b1c..b5be589afa 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -3,14 +3,29 @@ //! functions the server runs: `build_context_from_signals` decodes the raw //! consent signals and `assemble_permissions` resolves the policy. +use std::sync::Arc; + use serde::Deserialize; use serde_json::json; use trusted_server_core::consent::build_context_from_signals; use trusted_server_core::consent::types::RawConsentSignals; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::PermissionSignalProvider; use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; +/// The signal providers the inspector asks, in the order every adapter offers +/// them when `[permission_signal] sources` names none. +fn providers() -> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + /// The inspector's evaluation request. #[derive(Deserialize)] struct EvalInput { @@ -38,14 +53,20 @@ fn eval_json(input: &str) -> String { gpc: input.gpc, }; let ctx = build_context_from_signals(&signals); + // The page carries no request, only the consent signals above, and each of + // the four providers answers from the consent record rather than from + // request evidence, so empty evidence changes none of their answers. A + // provider that read a header or a cookie would need real evidence here. + let evidence = OwnedRequestInfo::default(); + let providers = providers(); let maps = PermissionMaps::standard(); let (state, jurisdiction) = match input.geo.as_str() { "failed" => { - let state = assemble_permissions(&ctx, GeoStatus::Failed); + let state = assemble_permissions(&ctx, &evidence, GeoStatus::Failed, &providers); (state, "unknown".to_string()) } "none" => { - let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + let state = assemble_permissions(&ctx, &evidence, GeoStatus::NoLocation, &providers); (state, jurisdiction_name(maps.default_jurisdiction())) } _ => { @@ -59,7 +80,8 @@ fn eval_json(input: &str) -> String { region: input.region.clone().filter(|r| !r.is_empty()), asn: None, }; - let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let state = + assemble_permissions(&ctx, &evidence, GeoStatus::Located(&info), &providers); let jurisdiction = jurisdiction_name( maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), ); @@ -83,7 +105,8 @@ fn eval_json(input: &str) -> String { fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { let name = format!("{j:?}").to_lowercase(); let name = name.split('(').next().unwrap_or(&name).to_string(); - name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") + name.replace("usstate", "us-state") + .replace("nonregulated", "non-regulated") } fn validate_json(yaml: &str) -> String { From a2a1dfce78e9483c0530d9f999809127561ed7f8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 23:17:22 +0100 Subject: [PATCH 46/82] Name the permissions sample for what it is Renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml` and says at the top of the file, in the guide and on the constant that the file is for testing and evaluation only, is not a production policy and is not legal advice. "Vanilla" described the flavour of the rules and said nothing about whether anyone should run them, which is the thing a reader needs to know first. The display name becomes "Sample (testing and evaluation only)", so the inspector's dropdown carries the warning wherever the page is opened. The build script globs `config/permissions/*.yaml`, so its manifest picks the new name up with no change. `include_str!` in `permissions.rs` follows the rename. The doc comment above it is rewrapped and loses a clause-joining semicolon. The guide gains a short paragraph saying the same thing. The word "vanilla" is left alone where it means plain JavaScript, in the DataDome and Lockr script guards and in an older plan document. Tests. `cargo fmt --all --check` is clean, the core suite passes 2,851 tests, and `./scripts/build-inspector-wasm.sh` builds and writes a manifest reading `{"file":"sample.yaml","name":"Sample (testing and evaluation only)"}`. The docs Prettier configuration sets `proseWrap: preserve`, so the shorter path cannot change the formatting. --- config/permissions/{vanilla.yaml => sample.yaml} | 15 ++++++++++----- crates/trusted-server-core/src/permissions.rs | 9 ++++++--- docs/guide/permission-model.md | 5 ++++- trusted-server.example.toml | 2 +- 4 files changed, 21 insertions(+), 10 deletions(-) rename config/permissions/{vanilla.yaml => sample.yaml} (97%) diff --git a/config/permissions/vanilla.yaml b/config/permissions/sample.yaml similarity index 97% rename from config/permissions/vanilla.yaml rename to config/permissions/sample.yaml index dfb0452bd8..a5c19c8f79 100644 --- a/config/permissions/vanilla.yaml +++ b/config/permissions/sample.yaml @@ -9,13 +9,18 @@ # vocabulary this project invented, so a reader can check it against the # taxonomy rather than against us. # +# This file is a sample for testing and evaluation only. It is not a +# production policy, it is not legal advice, and no deployment should run it +# as it stands. Read it, change it, or replace it, and have whoever is +# accountable for the deployment decide what it should say. +# # No policy ever ships with Trusted Server. The builder of a deployment # chooses the permissions.yaml compiled into their image, an operator overlay # is the recorded follow-on, and the visitor's signals decide the rest at -# runtime. This copy is the repository's plain vanilla sample, compiled into -# test and demo builds so the rules stay visible and reviewable in version -# control. The samples live in config/permissions, and choosing one is always -# an explicit choice by whoever builds, configures, or inspects. +# runtime. This copy is compiled into test and demo builds so the rules stay +# visible and reviewable in version control. The samples live in +# config/permissions, and choosing one is always an explicit choice by whoever +# builds, configures, or inspects. # # There are three parts: # groups named permission baselines, defined once and referenced by rules @@ -106,7 +111,7 @@ # Named baselines. Each group lists every Data Use and its flag, so a group's # meaning is explicit. (A `default: ` shorthand is also accepted for any # Data Use a group omits.) -name: Vanilla +name: Sample (testing and evaluation only) groups: # European Union and EEA, where the model is opt-in, so every modeled ad-tech diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index b888a47fd3..ff2e23d292 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -475,9 +475,12 @@ pub struct PermissionMaps { } /// The default permission rules, compiled into the build from the repository's -/// vanilla sample in `config/permissions`. A deployer chooses or replaces the -/// compiled-in file to set the default policy; it is not read at runtime. -const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/vanilla.yaml"); +/// sample in `config/permissions`. +/// +/// That sample is for testing and evaluation only and is not a production +/// policy. A deployer chooses or replaces the compiled-in file to set the +/// default policy, which is not read at runtime. +const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/sample.yaml"); /// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. fn region_key(country: &str, region: &str) -> String { diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 5a34f7aeb2..32b0278eec 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -185,8 +185,11 @@ The Edge Cookie `Set-Cookie` operation always requires `necessary.operations.sto ## Groups and rules -The policy lives in a human-editable permissions YAML document. The repository sample is `config/permissions/vanilla.yaml`, +The policy lives in a human-editable permissions YAML document. The repository sample is `config/permissions/sample.yaml`, compiled into the build, so policy owners read and change it in version control. +That sample is for testing and evaluation only. It is not a production policy +and it is not legal advice, so a deployment reads it, changes it, or replaces +it, with whoever is accountable for that deployment deciding what it says. It has two parts. **Groups** are named baselines, each a set of permission flags. **Rules** are a single tree that says which group applies where. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index e41a44ff76..4fef496da2 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -141,7 +141,7 @@ pull_sync_concurrency = 3 # The permission baseline for a request the geo provider leaves unmatched # (and, with no geo provider, for every request) is the top of the rules tree # in the permissions.yaml compiled into the build (the repository sample is -# config/permissions/vanilla.yaml). Edit that file to +# config/permissions/sample.yaml). Edit that file to # change it. # # With no geo provider, every request is treated as that top node, so a From f5b9c04af8a6ed1c036aadf1763fe5a95411818f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 13:14:18 +0100 Subject: [PATCH 47/82] Select permission signal providers with provider, named in snake_case Every provider type selects the same way, with provider in its own section, and names its providers in snake_case. Permission signals now follow that, so [permission_signal] sources becomes [permission_signal] provider. The key stays an ordered list, and a configuration that leaves it out still runs every provider the adapter links, in the order the adapter offers them. The two hyphenated provider names become gpp_sale_opt_out and us_privacy, while gpc and tcf are unchanged. The crates report the new identifiers, and core's selection messages, the startup log, the module README, the guides, the example configuration and the permissions sample follow. sources is removed rather than accepted alongside provider, which breaks a configuration written against the branch that introduced it. A section carrying sources is refused when the settings are read, with a message naming provider, on each path a deployment reads settings through, being a TOML file, the TOML value ts config push reads, and the JSON config blob read at startup. Reading the section by hand rather than with deny_unknown_fields is what makes that message possible, because a derived refusal can only name sources by declaring it as a field, and would then offer it as a key it expects whenever it refused any other. No provider takes settings, so a [permission_signal.] block stays an unknown field. Its refusal now says that a provider which gains settings will take them there. Tests cover the removed key on each of those paths, a settings block refused as an unknown field, a list surviving a config blob round trip, and, where the real crates are linked, the documented names selecting all four providers in order and an old hyphenated name being refused with the names available. --- AGENTS.md | 4 +- config/permissions/sample.yaml | 4 +- crates/permission-signal/gpc/src/lib.rs | 2 +- crates/permission-signal/gpp/src/lib.rs | 4 +- crates/permission-signal/tcf/src/lib.rs | 2 +- .../permission-signal/us-privacy/src/lib.rs | 4 +- crates/trusted-server-adapter-axum/src/app.rs | 2 +- .../tests/permission_signals.rs | 52 ++++- .../src/app.rs | 2 +- .../trusted-server-adapter-fastly/src/app.rs | 2 +- crates/trusted-server-adapter-spin/src/app.rs | 2 +- .../src/permission_signal/README.md | 26 ++- .../src/permission_signal/mod.rs | 27 +-- crates/trusted-server-core/src/permissions.rs | 2 +- crates/trusted-server-core/src/settings.rs | 178 ++++++++++++++++-- docs/guide/permission-model.md | 2 +- docs/guide/permission-signals.md | 27 ++- tools/permissions-inspector/wasm/src/lib.rs | 2 +- trusted-server.example.toml | 26 ++- 19 files changed, 289 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7972540e80..cb7cbcb252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -365,7 +365,7 @@ deployment selects an implementation and the core stays neutral: | Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | | Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | | Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | -| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] sources` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] provider` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | Principles for adding or changing a provider: @@ -424,7 +424,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] sources` list) | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] provider` list) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/config/permissions/sample.yaml b/config/permissions/sample.yaml index a5c19c8f79..165fe80d95 100644 --- a/config/permissions/sample.yaml +++ b/config/permissions/sample.yaml @@ -513,7 +513,7 @@ rules: # Data Use is the TCF scheme's knowledge and lives in the TCF permission signal # provider crate, crates/permission-signal/tcf, so a deployment that runs no # TCF carries no table of another scheme's numbers. Which schemes run at all, -# and in what order, is [permission_signal] sources in trusted-server.toml. +# and in what order, is [permission_signal] provider in trusted-server.toml. signals: # A present TCF v2 record (a standalone TC string, or the EU TCF section of a # GPP string). With authoritative true, the record's consent to a purpose @@ -521,7 +521,7 @@ signals: # revokes them, whereas with authoritative false the record is ignored. # The flag governs only whether the record answers. Whether its answer # stands over an opt-out signal below, or the opt-out over it, is the order - # the providers are asked in, [permission_signal] sources in + # the providers are asked in, [permission_signal] provider in # trusted-server.toml, where the last provider with an opinion decides. The # default order asks Global Privacy Control first, being a browser setting # with no interface of its own, and the schemes carrying a choice someone diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index 1f05fdc252..e2ef6b6431 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -13,7 +13,7 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. +/// `provider`, in logs, and when a peer consults it. pub const ID: &str = "gpc"; /// The `Sec-GPC` request header, Global Privacy Control. diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index e5b402e663..e77e66e699 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -11,8 +11,8 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. -pub const ID: &str = "gpp-sale-opt-out"; +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "gpp_sale_opt_out"; /// A GPP US sale opt-out, read from the `__gpp` string. #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 2229c99802..a73c5473e6 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -23,7 +23,7 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. +/// `provider`, in logs, and when a peer consults it. pub const ID: &str = "tcf"; /// TCF v2, when the policy says TCF answers for this deployment. diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index 9ef4316cbf..80bc49a1a1 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -14,8 +14,8 @@ use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInp use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; /// The stable identifier this provider answers to in `[permission_signal]` -/// `sources`, in logs, and when a peer consults it. -pub const ID: &str = "us-privacy"; +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "us_privacy"; /// A US Privacy string sale opt-out, read from `us_privacy`. #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 01a5696016..1d78523fb6 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -52,7 +52,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 99ec150221..44d06e35df 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -8,12 +8,13 @@ //! left off the list not running at all, and withdrawal being TCF's alone //! and scoped to the place. This sits in the Axum adapter's tests because //! it is the first crate that links all four, and core deliberately links -//! none. +//! none. The names a deployment writes in configuration are checked here for +//! the same reason, against the identifiers the real crates answer to. //! //! The consent records here are built by hand, so nothing in core's consent //! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy //! opt-out from a Global Privacy Control header in a US state when the consent -//! settings say to, and the `us-privacy` provider then acts on it, which is +//! settings say to, and the `us_privacy` provider then acts on it, which is //! why removing `gpc` from the list alone does not make that header inert. use std::sync::Arc; @@ -43,14 +44,19 @@ fn all_four() -> Vec> { ] } +/// Settings naming these identifiers in `[permission_signal] provider`. +fn settings_naming(names: &[&str]) -> Settings { + let mut settings = Settings::default(); + settings.permission_signal.provider = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + settings +} + /// The providers a deployment gets from naming these identifiers in -/// `[permission_signal] sources`, through the same entry point an adapter's +/// `[permission_signal] provider`, through the same entry point an adapter's /// composition root uses. fn configured(names: &[&str]) -> Arc<[Arc]> { - let mut settings = Settings::default(); - settings.permission_signal.sources = - Some(names.iter().map(|name| (*name).to_owned()).collect()); - build_permission_signal_providers(&settings, &all_four()) + build_permission_signal_providers(&settings_naming(names), &all_four()) .expect("should select providers this build offers") } @@ -139,6 +145,38 @@ fn us_ca_geo() -> GeoInfo { // Which providers run. // ---------------------------------------------------------------------- +#[test] +fn the_documented_names_select_every_shipped_provider_in_order() { + // The names the guide and the example configuration list, which must be + // the identifiers the shipped crates answer to. + let documented = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"]; + let selected: Vec<&str> = configured(&documented) + .iter() + .map(|provider| provider.id()) + .collect(); + assert_eq!( + selected, documented, + "each documented name selects the shipped provider it names, in the order written" + ); +} + +#[test] +fn an_old_hyphenated_name_is_refused_naming_the_names_available() { + for old in ["gpp-sale-opt-out", "us-privacy"] { + let Err(error) = build_permission_signal_providers(&settings_naming(&[old]), &all_four()) + else { + panic!("should refuse the hyphenated name `{old}`"); + }; + let message = format!("{error:?}"); + assert!( + message.contains(&format!("`{old}` is not available in this build")) + && message + .contains("Available providers are gpc, gpp_sale_opt_out, us_privacy, tcf"), + "the refusal names the old name and the names to write instead: {message}" + ); + } +} + #[test] fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { // A US-style opt-out drops a granted baseline, because the map granted diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 50a2fbc971..4544948ca9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -83,7 +83,7 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index fefcece1d3..cad5bf88c4 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -195,7 +195,7 @@ pub(crate) struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. pub(crate) ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 933502bf70..214c857840 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -78,7 +78,7 @@ pub struct AppState { /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). /// `None` for a deployment that selects no provider. ec_provider: Option>, - /// The permission signal providers `[permission_signal] sources` selects + /// The permission signal providers `[permission_signal] provider` selects /// from the scheme crates this adapter links, in the order they run. /// Selected once here so a name no crate answers to fails startup rather /// than the first request, and handed to every request's services. diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 42e2d01efd..e7034b2d0d 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -78,7 +78,7 @@ a deployment's to set, not this code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] ``` A provider not on the list does not run, and there is no separate switch. A @@ -86,12 +86,19 @@ publisher who does not want to act on Global Privacy Control removes `"gpc"` from the list, and the provider that reads the header then does not run. One caveat: core's consent pipeline can also synthesize a US Privacy opt-out from that header for a visitor in a US state, when the consent settings say to, -which they do by default, and the `us-privacy` provider then acts on the +which they do by default, and the `us_privacy` provider then acts on the record it produced. A publisher who wants the header to have no effect at all -turns that setting off as well. Leaving the section out entirely runs every -provider the adapter offers, in the order it offers them, so a signal is never -quietly ignored because someone forgot to list it. An unknown or repeated name -is refused at startup, so a typo cannot silently stop a scheme being honored. +turns that setting off as well. Leaving `provider` out, or the section +entirely, runs every provider the adapter offers, in the order it offers them, +so a signal is never quietly ignored because someone forgot to list it. An +unknown or repeated name is refused at startup, so a typo cannot silently stop +a scheme being honored. + +Providers are named in `snake_case`, lowercase words joined by underscores. A +provider that gains settings will take them in a `[permission_signal.]` +block named for it. None of the four here has settings, so `provider` is the +only key the section accepts, and a block or any other key is refused as an +unknown field rather than ignored. The default order asks the signal with no interface of its own first and the ones carrying a choice made through an interface after. Global Privacy @@ -157,7 +164,8 @@ the identifier never depended on the record. ## Writing a provider -Implement `PermissionSignalProvider` in a crate that depends on core. Answer +Implement `PermissionSignalProvider` in a crate that depends on core, and give +it an identifier in `snake_case`, which is the name configuration uses. Answer `Neutral` for a permission the provider has no opinion on, including when the signal it reads is absent from the request. Returning `Revoke` for an absent signal turns silence into refusal and would revoke the permission on every @@ -188,8 +196,8 @@ configuring nothing gets all four in this order: | Identifier | Crate | Reads | | ------------------ | ------------ | ------------------------------------------------- | | `gpc` | `gpc` | The `Sec-GPC` header, Global Privacy Control | -| `gpp-sale-opt-out` | `gpp` | The US sale opt-out in a GPP string | -| `us-privacy` | `us-privacy` | The sale opt-out in a US Privacy string | +| `gpp_sale_opt_out` | `gpp` | The US sale opt-out in a GPP string | +| `us_privacy` | `us-privacy` | The sale opt-out in a US Privacy string | | `tcf` | `tcf` | A TCF v2 record, with its purpose mapping in code | The three opt-outs are separate rather than one so that a publisher who does diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index f30871fa14..24545dd71d 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -146,6 +146,9 @@ impl<'a> SignalInput<'a> { pub trait PermissionSignalProvider: Send + Sync { /// Stable identifier, used in configuration, in logs, and by a peer /// looking this provider up through [`SignalInput::ask`]. + /// + /// Written in `snake_case`, lowercase words joined by underscores, for + /// example `gpp_sale_opt_out`. fn id(&self) -> &'static str; /// How this provider would amend `permission` for this request. @@ -314,7 +317,7 @@ pub(crate) fn select( return Err(Report::new(TrustedServerError::Configuration { message: format!( "Permission signal provider `{name}` is named more than once in \ - [permission_signal] sources. Each provider runs once, at one place \ + [permission_signal] provider. Each provider runs once, at one place \ in the order" ), })); @@ -373,16 +376,16 @@ pub fn build_permission_signal_providers( settings: &Settings, available: &[Arc], ) -> Result]>, Report> { - let configured = settings.permission_signal.sources.as_deref(); + let configured = settings.permission_signal.provider.as_deref(); let selected = select(available, configured)?; match configured { None => log::info!( "Permission signals: acting on every provider this build offers, [{}], no \ - [permission_signal] sources configured", + [permission_signal] provider configured", ids(&selected).join(", ") ), Some([]) => log::info!( - "Permission signals: acting on no provider, [permission_signal] sources is \ + "Permission signals: acting on no provider, [permission_signal] provider is \ empty, so every permission stays at its country and region baseline" ), Some(_) => log::info!( @@ -394,7 +397,7 @@ pub fn build_permission_signal_providers( if !left_out.is_empty() { log::warn!( "Permission signals: not acting on [{}], which are not in [permission_signal] \ - sources. A signal this deployment does not act on is read from the request \ + provider. A signal this deployment does not act on is read from the request \ and then ignored", left_out.join(", ") ); @@ -875,8 +878,8 @@ mod tests { fn four() -> Vec> { vec![ fixed("gpc", ConsentSignal::Neutral), - fixed("gpp-sale-opt-out", ConsentSignal::Neutral), - fixed("us-privacy", ConsentSignal::Neutral), + fixed("gpp_sale_opt_out", ConsentSignal::Neutral), + fixed("us_privacy", ConsentSignal::Neutral), fixed("tcf", ConsentSignal::Neutral), ] } @@ -886,7 +889,7 @@ mod tests { let selected = select(&four(), None).expect("should accept no configuration"); assert_eq!( ids(&selected), - vec!["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"], + vec!["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"], "a publisher who configures nothing acts on every scheme the build knows, so \ one is never ignored because they forgot to list it" ); @@ -894,11 +897,11 @@ mod tests { #[test] fn the_configured_order_is_the_order_they_run_in() { - let reversed = names(&["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"]); + let reversed = names(&["tcf", "us_privacy", "gpp_sale_opt_out", "gpc"]); let selected = select(&four(), Some(&reversed)).expect("should accept known names"); assert_eq!( ids(&selected), - vec!["tcf", "us-privacy", "gpp-sale-opt-out", "gpc"], + vec!["tcf", "us_privacy", "gpp_sale_opt_out", "gpc"], "the list is the order, not merely the membership" ); } @@ -923,7 +926,7 @@ mod tests { }; let message = format!("{error:?}"); assert!( - message.contains("not-a-provider") && message.contains("gpc, gpp-sale-opt-out"), + message.contains("not-a-provider") && message.contains("gpc, gpp_sale_opt_out"), "the refusal names the bad entry and what is available: {message}" ); } @@ -944,7 +947,7 @@ mod tests { let configured = names(&["gpc", "tcf"]); assert_eq!( omitted(&four(), Some(&configured)), - vec!["gpp-sale-opt-out", "us-privacy"], + vec!["gpp_sale_opt_out", "us_privacy"], "the providers the list leaves out are reported in the offered order" ); assert!( diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index ff2e23d292..d7933773ca 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -392,7 +392,7 @@ pub struct SignalPolicy { /// Whether a present TCF record's grants and revokes apply. Whether a /// consenting record then stands over an opt-out, or the opt-out over it, /// is decided by the order the providers are asked in, which is - /// `[permission_signal] sources`, not by this flag. + /// `[permission_signal] provider`, not by this flag. tcf_authoritative: bool, /// The signals that constitute a US-style opt-out. opt_out_sources: Vec, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2b37f1e4d7..e013f4f165 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -982,18 +982,23 @@ impl DeviceConfig { /// Which permission signal providers run, and in what order. /// -/// Mapped from the `[permission_signal]` TOML section. Unlike the `[ec]`, -/// `[geo]` and `[device]` selectors, which each name one provider, signals -/// compose: a request can carry a TCF string and a Global Privacy Control -/// header at once and both have something to say. So this names a list, and -/// the order is the policy, because the last provider with an opinion decides. +/// Mapped from the `[permission_signal]` TOML section, where `provider` +/// selects, as it does in `[ec]`, `[geo]` and `[device]`. Those each name one +/// provider, whereas signals compose, because a request can carry a TCF string +/// and a Global Privacy Control header at once and both have something to say. +/// So here `provider` names a list, and the order is the policy, because the +/// last provider with an opinion decides. +/// +/// A provider that gains settings will take them in a +/// `[permission_signal.]` block named for it. None of the providers that +/// ship has settings, so `provider` is the only key accepted, and any other key +/// is refused as an unknown field rather than silently ignored. /// /// See `crates/trusted-server-core/src/permission_signal/README.md`. -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Validate)] pub struct PermissionSignalConfig { /// The providers to run, in order, named by the identifier each provider - /// crate declares, for example `gpc`, `gpp-sale-opt-out`, `us-privacy` and + /// crate declares, for example `gpc`, `gpp_sale_opt_out`, `us_privacy` and /// `tcf` for the four that ship. /// /// Absent means every provider the adapter offers, in the order it offers @@ -1010,8 +1015,41 @@ pub struct PermissionSignalConfig { /// /// [`build_permission_signal_providers`]: /// crate::permission_signal::build_permission_signal_providers - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sources: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option>, +} + +/// Read by hand rather than derived, so that `sources`, the key `provider` +/// replaced, is refused with a message saying what to write instead. A derived +/// struct could only refuse it by name by declaring it as a field, and would +/// then list it among the keys it expects whenever it refused any other. +impl<'de> Deserialize<'de> for PermissionSignalConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut section = serde_json::Map::::deserialize(deserializer)?; + if section.contains_key("sources") { + return Err(serde::de::Error::custom( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead", + )); + } + if let Some(key) = section.keys().find(|key| key.as_str() != "provider") { + return Err(serde::de::Error::custom(format!( + "unknown field `{key}` in [permission_signal], expected `provider`. No \ + permission signal provider takes settings yet, so a \ + [permission_signal.] block is not accepted" + ))); + } + // Read as an option, so an explicit JSON null is the same as leaving + // the key out. + let provider = match section.remove("provider") { + Some(value) => serde_json::from_value(value).map_err(serde::de::Error::custom)?, + None => None, + }; + Ok(Self { provider }) + } } /// Geo / IP intelligence configuration. @@ -8758,17 +8796,30 @@ formats = [{{ width = 300, height = 250 }}] #[cfg(test)] mod permission_signal_config_tests { use super::*; + use serde_json::json; + + use crate::config::TrustedServerAppConfig; + use crate::test_support::tests::crate_test_settings_str; // Which names are valid is only known where the scheme crates are linked, // so the checks that a name matches an available provider, and that none // is repeated, live with the seam in `permission_signal::select`. What is // tested here is the shape of the section itself. + /// The test fixture's configuration with `section` written as its + /// `[permission_signal]` section. + fn settings_toml_with(section: &str) -> String { + format!( + "{}\n[permission_signal]\n{section}\n", + crate_test_settings_str() + ) + } + #[test] fn no_section_is_allowed_and_means_every_provider() { let config = PermissionSignalConfig::default(); assert!( - config.sources.is_none(), + config.provider.is_none(), "absent rather than empty, because the two mean opposite things" ); } @@ -8776,20 +8827,41 @@ mod permission_signal_config_tests { #[test] fn the_section_round_trips_through_toml() { let parsed: PermissionSignalConfig = - toml::from_str(r#"sources = ["gpc", "tcf"]"#).expect("should parse the section"); + toml::from_str(r#"provider = ["gpc", "tcf"]"#).expect("should parse the section"); assert_eq!( - parsed.sources.as_deref(), + parsed.provider.as_deref(), Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), "the order written is the order read, because the order is the policy" ); } + #[test] + fn the_section_round_trips_through_a_config_blob() { + // The section is written by derive and read by hand, so what a push + // writes into a blob must be what a deployment reads back from it. + let written = PermissionSignalConfig { + provider: Some(vec!["tcf".to_owned(), "gpc".to_owned()]), + }; + let blob = serde_json::to_value(&written).expect("should write the section"); + let read: PermissionSignalConfig = + serde_json::from_value(blob).expect("should read back what was written"); + assert_eq!(read, written, "the list and its order survive the blob"); + + let null: PermissionSignalConfig = serde_json::from_value(json!({ "provider": null })) + .expect("should read an explicit null"); + assert_eq!( + null, + PermissionSignalConfig::default(), + "an explicit null is the same as leaving the key out" + ); + } + #[test] fn an_empty_list_is_kept_apart_from_no_list() { let parsed: PermissionSignalConfig = - toml::from_str("sources = []").expect("should parse an empty list"); + toml::from_str("provider = []").expect("should parse an empty list"); assert_eq!( - parsed.sources.as_deref(), + parsed.provider.as_deref(), Some(&[][..]), "a publisher acting on no signal at all writes an empty list, and it must \ not read back as having written nothing" @@ -8798,7 +8870,81 @@ mod permission_signal_config_tests { #[test] fn an_unknown_key_is_refused() { - toml::from_str::(r#"source = ["gpc"]"#) + let error = toml::from_str::(r#"providers = ["gpc"]"#) .expect_err("should refuse a misspelled key rather than silently ignore it"); + assert!( + error + .to_string() + .contains("unknown field `providers` in [permission_signal], expected `provider`"), + "the refusal names the key it did not recognize and the one it accepts: {error}" + ); + } + + #[test] + fn the_removed_sources_key_is_refused_naming_provider() { + for written in [ + r#"sources = ["gpc", "tcf"]"#, + "provider = [\"gpc\", \"tcf\"]\nsources = [\"gpc\", \"tcf\"]", + ] { + let error = toml::from_str::(written) + .expect_err("should refuse the removed key, alone or beside its replacement"); + assert!( + error.to_string().contains( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead" + ), + "the refusal says which key to write instead: {error}" + ); + } + } + + #[test] + fn every_way_settings_are_read_refuses_the_removed_sources_key() { + let written = settings_toml_with(r#"sources = ["gpc", "tcf"]"#); + + let error = Settings::from_toml(&written).expect_err("should refuse the removed key"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "reading a TOML file names the key that replaced it: {error:?}" + ); + + // `ts config push` parses the file into a TOML value before reading the + // settings from it. + let value: toml::Value = toml::from_str(&written).expect("should parse as TOML"); + let error = value + .try_into::() + .expect_err("should refuse the removed key before a push"); + assert!( + error.to_string().contains("[permission_signal] provider"), + "a push names the key that replaced it: {error}" + ); + + // A deployment reads its settings from a JSON config blob. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + let mut blob = serde_json::to_value(settings).expect("should serialize the fixture"); + blob["permission_signal"] = json!({ "sources": ["gpc", "tcf"] }); + let error = + Settings::from_json_value(blob).expect_err("should refuse the removed key at startup"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "startup names the key that replaced it: {error:?}" + ); + } + + #[test] + fn a_block_of_provider_settings_is_refused_as_an_unknown_field() { + // No provider takes settings yet, so a block for one is refused rather + // than read and then ignored. + let written = + settings_toml_with("provider = [\"gpc\"]\n\n[permission_signal.gpc]\nenabled = true"); + let error = + Settings::from_toml(&written).expect_err("should refuse settings no provider takes"); + let message = format!("{error:?}"); + assert!( + message.contains("unknown field `gpc` in [permission_signal]") + && message.contains("[permission_signal.] block is not accepted"), + "the refusal names the block and says why it is refused: {message}" + ); } } diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index 32b0278eec..e674e0a8e0 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -354,7 +354,7 @@ a provider runs. A consent record that is present but cannot be decoded blocks baseline grants (fail-closed) rather than degrading to the no-signal baseline, ahead of every signal provider and whichever are configured. The providers are then asked in -the order `[permission_signal] sources` gives, each amending what the ones +the order `[permission_signal] provider` gives, each amending what the ones before it settled, and the last with an opinion decides. So which of a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) and a consenting TCF record stands when they disagree is the configured order, not a rule in code. diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md index bbfc7898cd..a31ac16c1c 100644 --- a/docs/guide/permission-signals.md +++ b/docs/guide/permission-signals.md @@ -74,7 +74,7 @@ deployment's to set, not the code's to assume. ```toml [permission_signal] -sources = ["gpc", "gpp-sale-opt-out", "us-privacy", "tcf"] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] ``` A provider not on the list does not run, and there is no separate switch. A @@ -82,19 +82,25 @@ publisher who does not want to act on Global Privacy Control removes `"gpc"` from the list, and the provider that reads the header then does not run. One caveat: the core's consent pipeline can also synthesize a US Privacy opt-out from that header for a visitor in a US state, when the consent settings say to, -which they do by default, and the `us-privacy` provider then acts on the record +which they do by default, and the `us_privacy` provider then acts on the record it produced. A publisher who wants the header to have no effect at all turns -that setting off as well. Leaving the section out entirely runs every provider -the adapter offers, in the order it offers them, so a signal is never quietly -ignored because someone forgot to list it. An empty list runs none of them, -which is a publisher acting on no signal at all, and leaves every permission -at its country and region baseline. +that setting off as well. Leaving `provider` out, or the section entirely, runs +every provider the adapter offers, in the order it offers them, so a signal is +never quietly ignored because someone forgot to list it. An empty list runs +none of them, which is a publisher acting on no signal at all, and leaves every +permission at its country and region baseline. A name matching no provider the adapter links, or a name given twice, is refused at startup rather than ignored, so a typo cannot silently stop a scheme being honored. What ran, and what was left out, is written to the log once at startup. +Providers are named in `snake_case`, lowercase words joined by underscores. A +provider that gains settings will take them in a `[permission_signal.]` +block named for it. None of the four that ship has settings, so `provider` is +the only key the section accepts, and a block or any other key is refused as +an unknown field rather than ignored. + The default order asks the signal with no interface of its own first and the ones carrying a choice made through an interface after. Global Privacy Control is a browser setting, so it revokes personalization on arrival, @@ -109,8 +115,8 @@ question about a jurisdiction and a publisher. | Identifier | Crate | Reads | | ------------------ | ------------------------------------- | ------------------------------------------------- | | `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | -| `gpp-sale-opt-out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | -| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | +| `gpp_sale_opt_out` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | +| `us_privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | | `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | The three opt-outs are separate so that a publisher who does not act on Global @@ -177,7 +183,8 @@ record at all, so it must not degrade to the no-signal baseline. 1. Create a crate that depends on `trusted-server-core` and implements `PermissionSignalProvider` from `trusted_server_core::permission_signal`. - Give it a stable identifier, which is the name configuration uses. + Give it a stable identifier in `snake_case`, which is the name + configuration uses. 2. Answer neutral for a permission the scheme has no opinion on, including when its signal is absent from the request. Reading an absent signal as a refusal would revoke the permission on every request that did not carry diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index b5be589afa..1778aa276b 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -16,7 +16,7 @@ use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; /// The signal providers the inspector asks, in the order every adapter offers -/// them when `[permission_signal] sources` names none. +/// them when `[permission_signal] provider` names none. fn providers() -> Vec> { vec![ Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 4fef496da2..0f158e49c6 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -211,24 +211,30 @@ pull_sync_concurrency = 3 # # Signals compose rather than select. A request can carry a TCF string and a # Global Privacy Control header at once, and both have something to say, so -# this is a list, whereas [ec], [geo] and [device] each name one provider. The -# order is the policy, because the last provider with an opinion decides. +# provider here is a list, whereas in [ec], [geo] and [device] it names one +# provider. The order is the policy, because the last provider with an opinion +# decides. # # Listed below is every provider the shipped adapters link, which is also -# exactly what runs when this section is absent. Each is a crate under -# crates/permission-signal, outside the core. Remove the ones this publisher -# does not want to act on. A provider that is not on the list does not run, -# and there is no separate switch to turn one off. An unknown or repeated name -# is refused at startup rather than quietly ignored. +# exactly what runs when provider is left out. Each is a crate under +# crates/permission-signal, outside the core. Providers are named in +# snake_case, lowercase words joined by underscores. Remove the ones this +# publisher does not want to act on. A provider that is not on the list does +# not run, and there is no separate switch to turn one off. An unknown or +# repeated name is refused at startup rather than quietly ignored. # # [permission_signal] -# sources = [ +# provider = [ # "gpc", # the Sec-GPC request header, Global Privacy Control -# "gpp-sale-opt-out", # a GPP US sale opt-out -# "us-privacy", # a US Privacy string sale opt-out +# "gpp_sale_opt_out", # a GPP US sale opt-out +# "us_privacy", # a US Privacy string sale opt-out # "tcf", # TCF v2, with its purpose mapping in the crate # ] # +# A provider that gains settings will take them in a [permission_signal.] +# block named for it. None of these four has settings, so such a block is +# refused rather than ignored. +# # The three opt-outs are separate entries so that a publisher who does not act # on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy # opt-outs working. From fe0b6d9fcb3caec26981796a6ce8d81b0477cd4b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 25 Sep 2026 09:51:44 +0100 Subject: [PATCH 48/82] Let the shared Next.js fixture say it serves one jurisdiction Main added a Next.js auction fixture that every adapter now shares. This branch refuses a document that configures an Edge Cookie provider without saying where its readers are, because otherwise every request resolves at the top of the rules tree, so the fixture acknowledges single-jurisdiction operation the way the other fixtures on this branch do. --- crates/trusted-server-core/src/test_support.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index a487c71bce..178fa7b342 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -193,6 +193,11 @@ pub mod nextjs_auction { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + # The fixture serves one publisher in one place, so it says so + # rather than selecting a geo provider it has no use for. + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse Next.js auction fixture settings"); From 22262a024f85e340c10cf18ae8ce372f925711e7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 25 Sep 2026 10:24:10 +0100 Subject: [PATCH 49/82] Let the cache withdrawal test withdraw on this branch's terms Main's new template cache test drives withdrawal with a Californian GPC signal and asserts the Edge Cookie is expired. On this branch that signal suppresses use without expiring anything, which `finalize_gpc_suppresses_headers_but_keeps_the_cookie` states as deliberate, because a visitor who later withdraws the opt-out keeps their identity. Only an explicit withdrawal of device storage is destructive. The test is about one template variant being shared across identities and across withdrawal, not about how withdrawal is signalled, so it now says it withdrew storage and keeps asserting the same thing. `EcContext` gains the test constructor for saying so, since the gated one gives suppression. --- crates/trusted-server-core/src/ec/mod.rs | 15 +++++++++++++ crates/trusted-server-core/src/publisher.rs | 25 ++++++++++++--------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 334366439c..eaf8918ba2 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1055,6 +1055,21 @@ impl EcContext { } } + /// Creates a test-only `EcContext` whose request explicitly withdrew + /// device storage, rather than merely not setting it. + /// + /// Withdrawal is destructive, expiring the browser cookie, where + /// suppression is not, so a test of the destructive path has to say which + /// of the two it means. [`new_for_test_gated`](Self::new_for_test_gated) + /// with `false` gives suppression. + #[cfg(test)] + #[must_use] + pub fn new_for_test_withdrawn(ec_value: Option, consent: ConsentContext) -> Self { + let mut context = Self::new_for_test_gated(ec_value, consent, false); + context.permissions = PermissionState::default().with_storage_withdrawn(true); + context + } + /// Creates a test-only [`EcContext`] with explicit client IP. #[cfg(test)] #[must_use] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 646d0ac33a..2130e039ff 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12837,18 +12837,23 @@ mod tests { .into_iter() .enumerate() { - let consent = if withdrawn { - ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::UsState( - "CA".to_owned(), - ), - gpc: true, - ..Default::default() - } + // A US-style opt-out suppresses use without expiring the + // cookie, so the destructive path this test is about needs + // a request that withdrew device storage outright. + let mut ec_context = if withdrawn { + EcContext::new_for_test_withdrawn( + Some(identity.clone()), + ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::UsState( + "CA".to_owned(), + ), + gpc: true, + ..Default::default() + }, + ) } else { - scheduling_consent() + EcContext::new_for_test(Some(identity.clone()), scheduling_consent()) }; - let mut ec_context = EcContext::new_for_test(Some(identity.clone()), consent); assert_eq!( ec_context.ec_allowed(), !withdrawn, From 796bb47f7d1b2e17259deceb7448ad5f787a1688 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:32:09 +0100 Subject: [PATCH 50/82] Say what happens when geolocation resolves no location The one-time warning told the operator that the jurisdiction would be unknown and Edge Cookie creation would fail closed. On this branch neither is true. With no location the caller supplies the declared jurisdiction, which is the permission policy's top node, so identity and the auction both keep running. The warning described behavior from before the policy tree existed. That matters because it is the line an operator reads when advertising stops. Being told identity is blocked when it is allowed sends them looking in the wrong place, and a deployment that selects no geo module sees this message on every start. It now says what happens rather than only what is missing. The jurisdiction is the declared one rather than one resolved from the request, country and region rules cannot match, and a lookup that fails, as opposed to resolving nothing, is the case that does fail closed. --- crates/trusted-server-core/src/consent/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 8b06b31a48..3272cf925c 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -545,13 +545,19 @@ fn log_consent_signals(signals: &RawConsentSignals) { } /// Logs a one-time warning when request geolocation is unavailable. +/// +/// It says what happens next rather than only that geo is missing, because an +/// operator reading it wants to know whether identity and advertising are +/// still running. With no location the caller supplies the declared +/// jurisdiction, which is the permission policy's top node, so they are. A +/// lookup that fails is the different case, and that one does fail closed. fn log_missing_geo_warning_once() { if MISSING_GEO_WARNING_LOGGED.swap(true, Ordering::Relaxed) { return; } log::warn!( - "Geo lookup returned no data; consent jurisdiction will be unknown and EC creation will fail closed" + "Geo lookup returned no location, so the jurisdiction is the one the permission policy declares rather than one resolved from the request. Country and region rules cannot match. A geo lookup that fails, as opposed to resolving nothing, is treated as unknown and fails closed." ); } From 207d04eea7ac9bc5dbca77fec216487e70081a6e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:32:15 +0100 Subject: [PATCH 51/82] Say which permissions are still waiting for a signal A permission whose baseline requires a signal, and for which every configured module answered neutral, was simply not in the resolved set, so a page could not tell "nobody has answered yet" from "refused". A prompt that never loaded then looked like a visitor who said no. PermissionState gains awaiting, the permissions in exactly that position, recorded by resolution alongside the set. It reaches the page as a third key of window.tsjs.permissions beside set and tdls, and the page core normalises it like the other two. Nothing yet acts on it. That is the next step, the page core holding what depends on an awaited permission. --- crates/trusted-server-core/src/permissions.rs | 145 +++++++++++++++--- .../lib/src/core/permissions.ts | 5 +- .../trusted-server-js/lib/src/core/types.ts | 5 + .../lib/test/core/permissions.test.ts | 15 +- 4 files changed, 138 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index d7933773ca..394929d171 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -714,22 +714,27 @@ impl PermissionMaps { ) -> PermissionState { let acquisition = |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); - let set = Permission::all() - .filter(|&permission| { - // The baseline is passed to the signal as well as applied to - // its answer, because a source amends the place rules and - // cannot amend what it cannot see. - let baseline = acquisition(permission); - match (baseline, signal(permission, baseline)) { - (Acquisition::Denied, _) => false, - (Acquisition::Granted, ConsentSignal::Revoke) => false, - (Acquisition::Granted, _) => true, - (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, - (Acquisition::RequiresSignal, _) => false, + let mut set = PermissionSet::none(); + let mut awaiting = PermissionSet::none(); + for permission in Permission::all() { + // The baseline is passed to the signal as well as applied to its + // answer, because a source amends the place rules and cannot + // amend what it cannot see. + let baseline = acquisition(permission); + match (baseline, signal(permission, baseline)) { + (Acquisition::Denied, _) => {} + (Acquisition::Granted, ConsentSignal::Revoke) => {} + (Acquisition::Granted, _) => set = set.with(permission), + (Acquisition::RequiresSignal, ConsentSignal::Grant) => set = set.with(permission), + (Acquisition::RequiresSignal, ConsentSignal::Revoke) => {} + // Requires a signal and nobody gave one. Not set, and not + // refused either, which the page has to be able to tell apart. + (Acquisition::RequiresSignal, ConsentSignal::Neutral) => { + awaiting = awaiting.with(permission); } - }) - .collect(); - PermissionState::new(set) + } + } + PermissionState::new(set).with_awaiting(awaiting) } /// The baseline permission state for a country and region with no session @@ -769,6 +774,9 @@ impl PermissionMaps { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PermissionState { set: PermissionSet, + /// The permissions whose baseline requires a signal and for which no + /// configured provider gave one. See [`awaiting`](Self::awaiting). + awaiting: PermissionSet, /// Whether the request explicitly withdrew device storage, as opposed to /// storage merely not being set. See /// [`storage_withdrawn`](Self::storage_withdrawn). @@ -787,11 +795,38 @@ impl PermissionState { pub fn new(set: PermissionSet) -> Self { Self { set, + awaiting: PermissionSet::none(), storage_withdrawn: false, tdls: Arc::default(), } } + /// The same state, recording which permissions are still waiting for a + /// signal. Set by resolution from the baseline and the providers' answers. + #[must_use] + pub fn with_awaiting(self, awaiting: PermissionSet) -> Self { + Self { awaiting, ..self } + } + + /// The permissions whose baseline requires a signal and for which every + /// configured provider answered neutral, so nobody has answered yet. + /// + /// This is different from a permission that is not set. A refused + /// permission and one nobody has answered look the same in + /// [`permissions`](Self::permissions), and a page that cannot tell them + /// apart treats a prompt that never ran as a visitor who said no. A + /// permission is never both set and awaited. + #[must_use] + pub const fn awaiting(&self) -> PermissionSet { + self.awaiting + } + + /// Whether a permission is still waiting for a signal. + #[must_use] + pub const fn is_awaited(&self, permission: Permission) -> bool { + self.awaiting.contains(permission) + } + /// The same state, recording whether device storage was explicitly /// withdrawn. Set by assembly from what the signal providers answered, /// scoped to the jurisdiction's storage baseline. @@ -863,12 +898,14 @@ impl PermissionState { /// /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the /// same state always serializes to the same bytes whatever order the set - /// was built in. `tdls` carries the terms documents the data is available - /// under, in the order the providers were asked, so a page module reads the - /// terms alongside the permissions. An empty state renders as - /// `{"set":[],"tdls":[]}`, and both are answers (nothing is set, no terms - /// were declared) rather than missing values, so page code never has to - /// tell the two apart. + /// was built in. `awaiting` names the permissions still waiting for a + /// signal, so a page can hold what depends on them rather than read + /// "nobody has answered" as "refused". `tdls` carries the terms documents + /// the data is available under, in the order the providers were asked, so + /// a page module reads the terms alongside the permissions. An empty state + /// renders as `{"awaiting":[],"set":[],"tdls":[]}`, and all three are + /// answers (nothing is set, nothing is awaited, no terms were declared) + /// rather than missing values, so page code never has to tell them apart. /// /// This is the only place the page shape is spelled, so no caller writes /// the JSON by hand. @@ -885,20 +922,23 @@ impl PermissionState { /// ); /// assert_eq!( /// state.page_json(), - /// r#"{"set":["necessary.operations.storage"],"tdls":[]}"# + /// r#"{"awaiting":[],"set":["necessary.operations.storage"],"tdls":[]}"# /// ); /// /// assert_eq!( /// PermissionState::default().page_json(), - /// r#"{"set":[],"tdls":[]}"# + /// r#"{"awaiting":[],"set":[],"tdls":[]}"# /// ); /// ``` #[must_use] pub fn page_json(&self) -> String { let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); names.sort_unstable(); + let mut awaiting: Vec<&'static str> = + self.awaiting.iter().map(Permission::as_str).collect(); + awaiting.sort_unstable(); let tdls: Vec<&str> = self.tdls.iter().map(Tdl::as_str).collect(); - serde_json::json!({ "set": names, "tdls": tdls }).to_string() + serde_json::json!({ "set": names, "awaiting": awaiting, "tdls": tdls }).to_string() } } @@ -1406,6 +1446,7 @@ mod tests { "advertising_marketing.first_party.contextual", "necessary.operations.storage", ], + "awaiting": [], "tdls": [], }) .to_string(), @@ -1431,6 +1472,7 @@ mod tests { json, json!({ "set": ["necessary.operations.storage"], + "awaiting": [], "tdls": ["https://terms.example.com/marketing/2.txt"], }) .to_string(), @@ -1449,11 +1491,64 @@ mod tests { // Assert assert_eq!( json, - json!({ "set": [], "tdls": [] }).to_string(), + json!({ "set": [], "awaiting": [], "tdls": [] }).to_string(), "an empty state should render as an empty set and no declared terms, not as nothing" ); } + #[test] + fn a_permission_requiring_a_signal_that_nobody_gave_is_awaited() { + // Arrange: the requires-signal floor, where every permission needs a + // signal, and providers that answer nothing. + let state = PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral); + + // Assert: nothing is set and everything is awaited, which is the + // state a page must hold on rather than read as a refusal. + assert!( + state.permissions().is_empty(), + "should set nothing without a signal" + ); + assert!( + state.is_awaited(Permission::StoreOnDevice), + "should record that storage is still waiting for a signal" + ); + assert_eq!( + state.page_json(), + json!({ + "set": [], + "awaiting": Permission::all().map(Permission::as_str).collect::>(), + "tdls": [], + }) + .to_string(), + "the page should be told which permissions are still awaited" + ); + } + + #[test] + fn an_answered_or_baselined_permission_is_never_awaited() { + // Arrange: the floor again, with a provider that grants storage and + // revokes personalised advertising. + let state = PermissionMaps::floor_with(|permission, _| match permission { + Permission::StoreOnDevice => ConsentSignal::Grant, + Permission::SelectPersonalisedAds => ConsentSignal::Revoke, + _ => ConsentSignal::Neutral, + }); + + // Assert: a grant is set and not awaited, a refusal is neither set + // nor awaited, and a permission nobody answered is awaited. + assert!(state.is_set(Permission::StoreOnDevice)); + assert!( + !state.is_awaited(Permission::StoreOnDevice), + "a grant is an answer" + ); + assert!(!state.is_set(Permission::SelectPersonalisedAds)); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds), + "a refusal is an answer too, and must not look like no answer" + ); + assert!(state.is_awaited(Permission::SelectBasicAds)); + } + #[test] fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { // Empty maps and no top node: every permission is the requires-signal diff --git a/crates/trusted-server-js/lib/src/core/permissions.ts b/crates/trusted-server-js/lib/src/core/permissions.ts index 19929f2e5f..c850805e92 100644 --- a/crates/trusted-server-js/lib/src/core/permissions.ts +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -14,6 +14,7 @@ function normalize(snapshot: PermissionsSnapshot): PermissionsSnapshot { return { ...snapshot, set: Array.isArray(snapshot.set) ? snapshot.set : [], + awaiting: Array.isArray(snapshot.awaiting) ? snapshot.awaiting : [], tdls: Array.isArray(snapshot.tdls) ? snapshot.tdls : [], }; } @@ -33,7 +34,7 @@ export function installPermissions(api: TsjsApi): void { const injected = api.permissions; let current: PermissionsSnapshot = isSnapshot(injected) ? normalize(injected) - : { set: [], tdls: [] }; + : { set: [], awaiting: [], tdls: [] }; let settled = false; let resolvePending: (snapshot: PermissionsSnapshot) => void = () => {}; const pending = new Promise((resolve) => { @@ -51,7 +52,7 @@ export function installPermissions(api: TsjsApi): void { return current; }, set(value: PermissionsSnapshot) { - current = isSnapshot(value) ? normalize(value) : { set: [], tdls: [] }; + current = isSnapshot(value) ? normalize(value) : { set: [], awaiting: [], tdls: [] }; log.debug('permissions: received', current); settle(current); }, diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index aee6e12e67..0558368bc0 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -380,6 +380,11 @@ export interface GptSlotHandoff { */ export interface PermissionsSnapshot { set: string[]; + /** + * Permissions still waiting for a signal. Not set, and not refused + * either, which page code must tell apart from `set` being empty. + */ + awaiting: string[]; tdls: string[]; } diff --git a/crates/trusted-server-js/lib/test/core/permissions.test.ts b/crates/trusted-server-js/lib/test/core/permissions.test.ts index fe0ece6e53..5f7f3487c2 100644 --- a/crates/trusted-server-js/lib/test/core/permissions.test.ts +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -23,6 +23,7 @@ describe('core/permissions', () => { it('keeps permissions injected before the bundle loads and resolves with them', async () => { const injected: PermissionsSnapshot = { set: ['necessary.operations'], + awaiting: [], tdls: ['https://terms.example.com/marketing/2.txt'], }; window.tsjs = { permissions: injected } as TsjsApi; @@ -39,14 +40,16 @@ describe('core/permissions', () => { const api = window.tsjs as TsjsApi; const settled = api.whenPermissions!(); - api.permissions = { set: ['marketing.advertising.serving'], tdls: [] }; + api.permissions = { set: ['marketing.advertising.serving'], awaiting: [], tdls: [] }; await expect(settled).resolves.toEqual({ set: ['marketing.advertising.serving'], + awaiting: [], tdls: [], }); expect(api.permissions).toEqual({ set: ['marketing.advertising.serving'], + awaiting: [], tdls: [], }); }); @@ -58,7 +61,7 @@ describe('core/permissions', () => { const settled = api.whenPermissions!(); document.dispatchEvent(new Event('DOMContentLoaded')); - await expect(settled).resolves.toEqual({ set: [], tdls: [] }); + await expect(settled).resolves.toEqual({ set: [], awaiting: [], tdls: [] }); }); it('resolves at once when the document has already been parsed', async () => { @@ -68,19 +71,19 @@ describe('core/permissions', () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - await expect(api.whenPermissions!()).resolves.toEqual({ set: [], tdls: [] }); + await expect(api.whenPermissions!()).resolves.toEqual({ set: [], awaiting: [], tdls: [] }); }); it('returns the same resolved value from every later call', async () => { await import('../../src/core/index'); const api = window.tsjs as TsjsApi; - api.permissions = { set: ['analytics.reporting'], tdls: [] }; + api.permissions = { set: ['analytics.reporting'], awaiting: [], tdls: [] }; const first = await api.whenPermissions!(); const second = await api.whenPermissions!(); expect(second).toBe(first); - expect(second).toEqual({ set: ['analytics.reporting'], tdls: [] }); + expect(second).toEqual({ set: ['analytics.reporting'], awaiting: [], tdls: [] }); }); it('gives page code both lists when the edge sent only the permissions', async () => { @@ -94,6 +97,7 @@ describe('core/permissions', () => { expect(api.permissions.tdls).toEqual([]); await expect(api.whenPermissions!()).resolves.toEqual({ set: ['analytics.reporting'], + awaiting: [], tdls: [], }); }); @@ -104,6 +108,7 @@ describe('core/permissions', () => { api.permissions = { set: ['necessary.operations.storage'], + awaiting: [], tdls: ['https://terms.example.com/marketing/2.txt'], }; From c04649f0245b8ab133324d33820d666b9dbade9c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:32:21 +0100 Subject: [PATCH 52/82] Await only what a configured module could still grant The awaiting list said every permission that requires a signal and got none. Some of those no module in the deployment could ever grant, the marketing channel opt-ins in the shipped sample policy for one, and a page told to wait for them would wait for ever, which is the defect the list exists to remove. Each module now declares, through grants on the trait, which permissions it can answer Grant for under the policy it is given. An opt-out declares nothing. TCF declares every Data Use a purpose maps to, and nothing when the policy silences the record. The assembly narrows awaiting to the union of those declarations, so a permission nothing here could grant is simply unset. Start-up compares the policy against the declarations and warns, naming every permission the policy requires a signal for somewhere that no configured module can grant. It warns rather than refuses, because the shipped policy itself carries two such permissions and a deployment that never uses those channels is not misconfigured. PermissionSet gains intersect, union and without. PermissionMaps gains requires_signal_anywhere, the union over the whole tree. The guide and the seam's README describe awaiting. --- crates/permission-signal/tcf/src/lib.rs | 47 +++++- .../tests/permission_signals.rs | 75 ++++++++++ crates/trusted-server-core/src/ec/consent.rs | 58 +++++++ .../src/permission_signal/README.md | 24 +++ .../src/permission_signal/mod.rs | 141 +++++++++++++++++- crates/trusted-server-core/src/permissions.rs | 137 +++++++++++++++++ docs/guide/permission-model.md | 18 ++- docs/guide/permission-signals.md | 24 ++- 8 files changed, 517 insertions(+), 7 deletions(-) diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index a73c5473e6..bfa2505528 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -20,7 +20,7 @@ use trusted_server_core::consent::effective_tcf; #[cfg(test)] use trusted_server_core::consent::types::TcfConsent; use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; -use trusted_server_core::permissions::{ConsentSignal, Permission}; +use trusted_server_core::permissions::{ConsentSignal, Permission, PermissionSet, SignalPolicy}; /// The stable identifier this provider answers to in `[permission_signal]` /// `provider`, in logs, and when a peer consults it. @@ -74,6 +74,18 @@ impl PermissionSignalProvider for TcfProvider { } } + /// Every Data Use a purpose maps to, when the policy lets a record answer, + /// and nothing when it does not, because a silenced record grants nothing + /// and a page must not wait for it. + fn grants(&self, policy: &SignalPolicy) -> PermissionSet { + if !policy.tcf_authoritative() { + return PermissionSet::none(); + } + Permission::all() + .filter(|permission| mapping::purpose_for(*permission).is_some()) + .collect() + } + /// Only a TCF record refusing storage withdraws, because only TCF records /// a visitor declining the very signal storage depended on. A US-style /// opt-out suppresses use for the request and never destroys an identifier, @@ -213,6 +225,39 @@ mod tests { ); } + #[test] + fn declares_exactly_the_data_uses_a_purpose_maps_to() { + // Under the shipped policy a record answers, so every mapped Data Use + // is declared and an unmapped one is not. Under a policy that silences + // the record nothing is declared, because a record that cannot answer + // is not one a page should wait for. + let declared = TcfProvider::new().grants(shipped_policy()); + assert!( + declared.contains(Permission::StoreOnDevice), + "purpose 1 maps to storage, so storage is grantable" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !declared.contains(email), + "no purpose maps to a marketing channel, so it is not grantable" + ); + assert_eq!( + declared.iter().count(), + Permission::all() + .filter(|permission| purpose_for(*permission).is_some()) + .count(), + "should declare each mapped Data Use once and nothing else" + ); + assert!( + TcfProvider::new() + .grants(&SignalPolicy::default()) + .is_empty(), + "a silenced record grants nothing" + ); + } + #[test] fn a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { // The default policy declares no TCF block, so the record does not diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 44d06e35df..e417500768 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -512,3 +512,78 @@ fn an_expired_tcf_record_is_not_treated_as_malformed() { "expiry is its own explicit state, deliberately distinct from malformed" ); } + +#[test] +fn the_four_shipped_providers_leave_only_the_marketing_channels_unanswerable() { + // The sample policy requires a signal for the marketing channel opt-ins, + // which no shipped scheme reads, and for the ad-tech Data Uses, which + // TCF can grant. Start-up names the former and not the latter. + use trusted_server_core::permission_signal::never_granted; + use trusted_server_core::permissions::PermissionMaps; + + let named = never_granted(&all_four(), PermissionMaps::standard()); + let mut names: Vec<&str> = named.iter().map(Permission::as_str).collect(); + names.sort_unstable(); + assert_eq!( + names, + vec![ + "advertising_marketing.communications.email", + "advertising_marketing.communications.sms", + ], + "with all four providers only the channels no scheme carries are unanswerable" + ); + + let without_tcf = never_granted(&all_but("tcf"), PermissionMaps::standard()); + assert!( + without_tcf.contains(Permission::StoreOnDevice), + "removing TCF leaves storage with no provider able to grant it" + ); +} + +#[test] +fn a_visitor_in_the_eu_with_no_record_is_awaiting_what_tcf_could_grant() { + let geo = GeoInfo { + city: String::new(), + country: "FR".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + let state = assembled( + &ConsentContext::default(), + GeoStatus::Located(&geo), + &all_four(), + ); + assert!( + state.is_awaited(Permission::StoreOnDevice) + && state.is_awaited(Permission::SelectPersonalisedAds), + "a prompt that has not run leaves the TCF-mapped Data Uses awaited" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !state.is_awaited(email), + "a channel no provider can grant is not awaited" + ); + let state = assembled( + &ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }, + GeoStatus::Located(&geo), + &all_four(), + ); + assert!( + state.is_set(Permission::StoreOnDevice) && !state.is_awaited(Permission::StoreOnDevice), + "an answered prompt settles what it granted" + ); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds) + && !state.is_set(Permission::SelectPersonalisedAds), + "a record refusing a purpose is a refusal, not an awaited answer" + ); +} diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 11657c9cd0..229798f9d6 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -131,6 +131,9 @@ pub fn assemble_permissions( storage_acquisition(geo), ); state + // Only a permission some configured provider could still grant is + // worth a page waiting for. The rest are unset, not pending. + .awaiting_only(permission_signal::answerable(providers, maps.signals())) .with_storage_withdrawn(withdrawn) .with_tdls(permission_signal::tdls(providers, consent, evidence)) } @@ -211,6 +214,7 @@ mod tests { use super::*; use crate::evidence::OwnedRequestInfo; use crate::permission_signal::SignalInput; + use crate::permissions::PermissionSet; use crate::test_support::tests::create_test_settings; /// A provider that grants every permission, standing in for a scheme that @@ -283,6 +287,60 @@ mod tests { } } + /// A provider that could grant storage and has not, standing in for a + /// prompt that has not been answered yet. + struct Undecided; + + impl PermissionSignalProvider for Undecided { + fn id(&self) -> &'static str { + "undecided" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) + } + } + + #[test] + fn only_a_permission_a_provider_could_still_grant_is_awaited() { + // Arrange: the top node requires a signal for storage and for the + // marketing channels, and the one provider could grant storage only. + let providers: Vec> = vec![Arc::new(Undecided)]; + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &providers, + ); + + // Assert: storage is awaited, and a channel nothing here could grant + // is neither set nor awaited. + assert!( + state.is_awaited(Permission::StoreOnDevice), + "should await the permission the provider has yet to answer" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !state.is_awaited(email) && !state.is_set(email), + "should not tell a page to wait for a permission no provider can grant" + ); + } + + #[test] + fn nothing_is_awaited_when_no_provider_runs() { + // A publisher acting on no signal at all has nothing to wait for. + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); + assert!( + state.awaiting().is_empty(), + "with no provider nothing can arrive, so nothing is awaited" + ); + } + #[test] fn a_provider_declaring_terms_reaches_the_assembled_state() { let consent = ConsentContext::default(); diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index e7034b2d0d..08f93a7533 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -149,6 +149,30 @@ the only one. The name matches the `tdl` member the Data Labels work puts on a node of an `OpenRTB` request, which is where these travel once a bid request carries them. +## Who could still answer + +A permission whose baseline requires a signal, and for which every provider +answered `Neutral`, is not set, and it is also not refused. Resolution records +it as awaited, and the page reads the list as `awaiting` beside `set`, so a +prompt that has not run yet can be told from a visitor who said no. + +Waiting is only right for a permission some configured provider could grant. +So each provider declares, through `grants` on the trait, which permissions it +can ever answer `Grant` for under the policy it is given. An opt-out revokes and +declares nothing. TCF declares every Data Use a purpose maps to, and nothing +when the policy silences the record. The assembly keeps as awaited only what +some provider declared, and everything else that requires a signal and got none +is simply unset. + +Start-up compares the policy against the declarations and logs, at warn, every +permission the policy requires a signal for somewhere that no configured +provider can grant. It does not refuse, because the shipped sample policy +itself carries two such permissions, the marketing channel opt-ins, which +arrive through a scheme none of the four shipped providers reads, and a +deployment that never uses those channels is not misconfigured for leaving them +unset. A refusal belongs to whatever depends on such a permission, which is +the check a page module gets when modules declare what they need. + ## Withdrawal is a separate question A provider may also say that the request explicitly *withdraws* a permission, diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 24545dd71d..bf8821cb07 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -7,7 +7,9 @@ use error_stack::Report; use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::RequestInfo; -use crate::permissions::{Acquisition, ConsentSignal, Permission, SignalPolicy}; +use crate::permissions::{ + Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionSet, SignalPolicy, +}; use crate::settings::Settings; use crate::tdl::Tdl; @@ -154,6 +156,26 @@ pub trait PermissionSignalProvider: Send + Sync { /// How this provider would amend `permission` for this request. fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; + /// The permissions this provider can ever answer [`ConsentSignal::Grant`] + /// for under `policy`, so a permission a signal could still set can be + /// told from one nothing in this deployment will ever set. + /// + /// Resolution records a permission that requires a signal and got none as + /// awaited, and a page holds what depends on an awaited permission until + /// an answer arrives. That is right only for a permission some configured + /// provider could grant. For any other, waiting is waiting for ever, so + /// the assembly narrows the awaited list to the union of these + /// declarations, and start-up names the permissions the policy requires a + /// signal for that no provider can grant. + /// + /// A scheme that only ever revokes, which is every opt-out, leaves this + /// at its default of nothing. A scheme that grants declares exactly what + /// it maps, under the policy it is given, so a record the policy does not + /// let answer declares nothing either. + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none() + } + /// Whether the request carries an explicit withdrawal of `permission` /// under this scheme, as opposed to merely not granting it. /// @@ -290,6 +312,38 @@ pub(crate) fn tdls( Arc::from(declared) } +/// Every permission some provider in `providers` declares it can grant under +/// `policy`, which is the most a signal arriving later could still set. +#[must_use] +pub fn answerable( + providers: &[Arc], + policy: &SignalPolicy, +) -> PermissionSet { + providers + .iter() + .map(|provider| provider.grants(policy)) + .fold(PermissionSet::none(), PermissionSet::union) +} + +/// The permissions `maps` requires a signal for somewhere that no provider in +/// `providers` can grant, so they stay unset for every visitor of that place. +/// +/// Start-up names these in the log. It does not refuse them, because the +/// shipped sample policy itself carries two, the marketing channel opt-ins +/// that arrive through a scheme none of the four shipped providers reads, and +/// a deployment running only those four is not misconfigured for leaving a +/// channel it never uses unset. A refusal belongs to a module that depends on +/// such a permission, which is the check the page side adds when modules +/// declare what they need. +#[must_use] +pub fn never_granted( + providers: &[Arc], + maps: &PermissionMaps, +) -> PermissionSet { + maps.requires_signal_anywhere() + .without(answerable(providers, maps.signals())) +} + /// The providers a deployment named, in the order it named them, drawn from /// the ones the build makes available. /// @@ -402,6 +456,16 @@ pub fn build_permission_signal_providers( left_out.join(", ") ); } + let unanswerable = never_granted(&selected, PermissionMaps::standard()); + if !unanswerable.is_empty() { + let names: Vec<&str> = unanswerable.iter().map(Permission::as_str).collect(); + log::warn!( + "Permission signals: the policy requires a signal for [{}] somewhere and no \ + configured provider can grant them, so they stay unset there and a page is \ + never told to wait for them", + names.join(", ") + ); + } Ok(Arc::from(selected)) } @@ -491,6 +555,81 @@ mod tests { Arc::new(Fixed(id, signal)) } + /// A provider that declares it can grant one permission, whatever it then + /// answers, standing in for a scheme with a mapping. + struct Granting(&'static str, Permission); + + impl PermissionSignalProvider for Granting { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(self.1) + } + } + + #[test] + fn a_provider_declares_nothing_grantable_unless_it_says_otherwise() { + // An opt-out only ever revokes, so the default declaration is empty + // and a deployment of opt-outs alone can grant nothing. + let policy = SignalPolicy::default(); + assert!( + fixed("opt_out", ConsentSignal::Revoke) + .grants(&policy) + .is_empty() + ); + assert!(answerable(&[fixed("a", ConsentSignal::Grant)], &policy).is_empty()); + } + + #[test] + fn what_is_answerable_is_the_union_of_every_declaration() { + let policy = SignalPolicy::default(); + let providers: Vec> = vec![ + Arc::new(Granting("storage", Permission::StoreOnDevice)), + fixed("opt_out", ConsentSignal::Revoke), + Arc::new(Granting("profiling", Permission::CreateAdsProfile)), + ]; + assert_eq!( + answerable(&providers, &policy), + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::CreateAdsProfile), + "should collect what every provider declares, in any order" + ); + } + + #[test] + fn start_up_names_what_requires_a_signal_that_nobody_can_grant() { + // Arrange: a policy requiring a signal for storage and profiling, and + // a deployment whose one granting provider covers storage only. + let maps = PermissionMaps::empty().with_default_rules( + crate::permissions::CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::RequiresSignal) + .with_rule(Permission::CreateAdsProfile, Acquisition::RequiresSignal), + ); + let providers: Vec> = + vec![Arc::new(Granting("storage", Permission::StoreOnDevice))]; + + // Assert: profiling is named, storage is not. + assert_eq!( + never_granted(&providers, &maps), + PermissionSet::none().with(Permission::CreateAdsProfile), + "should name only the permission no provider can grant" + ); + assert_eq!( + never_granted(&[], &maps), + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::CreateAdsProfile), + "with no provider, everything requiring a signal is named" + ); + } + #[test] fn a_provider_declaring_no_terms_leaves_the_list_empty() { let consent = ConsentContext::default(); diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 394929d171..2cb882a730 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -274,6 +274,24 @@ impl PermissionSet { self.0 & other.0 == other.0 } + /// The permissions in both this set and `other`. + #[must_use] + pub const fn intersect(self, other: PermissionSet) -> Self { + Self(self.0 & other.0) + } + + /// The permissions in either this set or `other`. + #[must_use] + pub const fn union(self, other: PermissionSet) -> Self { + Self(self.0 | other.0) + } + + /// The permissions in this set and not in `other`. + #[must_use] + pub const fn without(self, other: PermissionSet) -> Self { + Self(self.0 & !other.0) + } + /// Iterates the permissions in the set, in bit-index order. /// /// The built-ins read nothing from the full set; this serves a provider or @@ -654,6 +672,36 @@ impl PermissionMaps { .and_then(|code| self.by_country.get(&code)) } + /// Every permission that requires a signal somewhere in the tree, at the + /// top node, in any country or in any region. + /// + /// This is the set a deployment's providers have to be able to grant + /// between them, because a permission in it that no provider can grant + /// stays unset for every visitor of that place. Start-up compares the two + /// and says so, see + /// [`never_granted`](crate::permission_signal::never_granted). A map with + /// no top node reports every permission, because such a map resolves an + /// unlisted place at the requires-signal floor. + #[must_use] + pub fn requires_signal_anywhere(&self) -> PermissionSet { + let requiring = |rules: &CountryRules| { + Permission::all() + .filter(|permission| { + matches!(rules.rule_for(*permission), Acquisition::RequiresSignal) + }) + .collect::() + }; + let top = self + .default_rules + .as_ref() + .map_or_else(|| Permission::all().collect(), requiring); + self.by_country + .values() + .chain(self.by_region.values()) + .map(requiring) + .fold(top, PermissionSet::union) + } + /// The rules a request resolves to: its region, else its country, else the /// top node of the tree. /// @@ -808,6 +856,22 @@ impl PermissionState { Self { awaiting, ..self } } + /// The same state, awaiting only the permissions in `answerable`. + /// + /// Resolution records every permission that requires a signal and got + /// none. Some of those no configured provider could ever grant, for + /// example a marketing channel opt-in in a deployment that runs only the + /// four shipped schemes, and a page told to wait for them would wait for + /// ever. So the assembly narrows the list to what some provider declares + /// it can grant, and the rest are simply not set. + #[must_use] + pub fn awaiting_only(self, answerable: PermissionSet) -> Self { + Self { + awaiting: self.awaiting.intersect(answerable), + ..self + } + } + /// The permissions whose baseline requires a signal and for which every /// configured provider answered neutral, so nobody has answered yet. /// @@ -1524,6 +1588,79 @@ mod tests { ); } + #[test] + fn awaiting_is_narrowed_to_what_a_provider_could_still_grant() { + // Arrange: the floor, where everything is awaited, and a deployment + // whose providers can only ever grant storage. + let state = PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral) + .awaiting_only(PermissionSet::none().with(Permission::StoreOnDevice)); + + // Assert: storage is still awaited, and a permission nobody could + // grant is not, because no page should wait for it. + assert!(state.is_awaited(Permission::StoreOnDevice)); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds), + "should not await a permission no configured provider can grant" + ); + assert!( + state.permissions().is_empty(), + "narrowing what is awaited should set nothing" + ); + } + + #[test] + fn the_permissions_requiring_a_signal_anywhere_are_the_union_over_the_tree() { + // Arrange: a top node granting everything, one country requiring a + // signal for storage, and a region requiring one for profiling. + let maps = PermissionMaps::empty() + .with_default_rules(CountryRules::with_default(Acquisition::Granted)) + .with_country( + "FR", + CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::RequiresSignal), + ) + .with_region( + "US", + "CA", + CountryRules::with_default(Acquisition::Denied) + .with_rule(Permission::CreateAdsProfile, Acquisition::RequiresSignal), + ); + + // Assert: both places contribute, and nothing else does. + let expected = PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::CreateAdsProfile); + assert_eq!( + maps.requires_signal_anywhere(), + expected, + "should collect every requires-signal rule from every node" + ); + assert_eq!( + PermissionMaps::empty().requires_signal_anywhere(), + Permission::all().collect::(), + "a map with no top node resolves at the floor, so everything requires a signal" + ); + } + + #[test] + fn the_shipped_policy_requires_a_signal_for_the_marketing_channels() { + // The sample policy says these arrive through their own opt-in, which + // no shipped scheme supplies, so start-up will name them. + let requiring = PermissionMaps::standard().requires_signal_anywhere(); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!(requiring.contains(email)); + assert!(requiring.contains(Permission::StoreOnDevice)); + let security = Permission::all() + .find(|permission| permission.as_str() == "necessary.operations.security") + .expect("the taxonomy should carry security"); + assert!( + !requiring.contains(security), + "a permission granted everywhere requires a signal nowhere" + ); + } + #[test] fn an_answered_or_baselined_permission_is_never_awaited() { // Arrange: the floor again, with a provider that grants storage and diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md index e674e0a8e0..0203b9f08a 100644 --- a/docs/guide/permission-model.md +++ b/docs/guide/permission-model.md @@ -423,10 +423,21 @@ the `permissions.yaml` keys and `Permission::as_str()`. "set": [ "necessary.operations.storage", "analytics.ad_reporting.market_research" - ] + ], + "awaiting": [ + "advertising_marketing.first_party.targeted" + ], + "tdls": [] } ``` +`awaiting` lists the Data Uses whose baseline requires a signal, which some +configured signal provider could grant, and for which no provider has given +one on this request. It is how a page tells a prompt that has not been answered +from a visitor who refused, since both leave the Data Use out of `set`. +`tdls` lists the terms documents the request's data is available under, as +declared by the signal providers. + Delivery follows the pattern already used for `adSlots` and `bids`, and the timing depends on how the page is assembled. Under inline assembly the value is injected as a ` - +