diff --git a/.cargo/config.toml b/.cargo/config.toml index aea0df2d5..ee1ea7224 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,20 +26,22 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass # --all-features, so without this the reusable-sandbox code is linted by # `clippy-fastly` but its tests never execute. test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip1 --features reusable-sandbox" # --- Axum adapter (native dev server) --- +# Test and clippy also cover the permission signal crates, whose own tests +# run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml new file mode 100644 index 000000000..5d58b3d93 --- /dev/null +++ b/.github/workflows/inspector.yml @@ -0,0 +1,26 @@ +name: "Permissions Inspector" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-inspector-wasm: + name: build inspector wasm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep -oP 'channel = "\K[^"]+' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + - name: Build the inspector engine + run: ./scripts/build-inspector-wasm.sh diff --git a/AGENTS.md b/AGENTS.md index 6ff981ebf..4fb0087d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,11 @@ 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 module) + edgecookie/ # vendor Edge Cookie module crates (built-in HMAC module is in core) + geo/ # vendor geo module crates (host geo is injected by the adapter) + permission-signal/ # permission signal module 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 ``` @@ -61,7 +66,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 @@ -154,6 +161,24 @@ 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 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`, +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. +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. + --- ## Coding Conventions @@ -281,12 +306,48 @@ 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 module declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +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. + +- A module that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated module **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every module and every integration sees +all the evidence available for a request, including host signals such as the TLS +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 +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 module'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 + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -303,6 +364,42 @@ Bad: `"fix: added feature flags"` --- +## Module 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 | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | Disabled, no location (default) | `crates/geo/` | +| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | None, and with no module every permission stays at its country and region baseline | `crates/permission-signal/` | + +Principles for adding or changing a module: + +- **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_module` + and `build_geo_module`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Modules read request evidence, not a fixed parameter set.** A module 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 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 module triggers no + host signal calls. +- **Modules 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 module + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor modules share a backend (for example a vendor's Edge Cookie, + geo, and device module on one cloud pipeline) they share a single call per + request rather than calling independently. Give a module the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -332,14 +429,14 @@ IntegrationRegistration::builder(ID) ## Configuration Files -| File | Purpose | -| ----------------------------- | ------------------------------------------------------------------------------------------------- | -| `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.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 | +| File | Purpose | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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, which carries the `[ec]`, `[geo]` and `[device]` module selectors and the `[permission-signal] modules` 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 1978230db..18211f83e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6504,6 +6504,11 @@ dependencies = [ "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -6525,6 +6530,11 @@ dependencies = [ "toml", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "worker", ] @@ -6550,6 +6560,13 @@ dependencies = [ "serde_json", "toml", "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-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "urlencoding", ] @@ -6574,6 +6591,11 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -6665,6 +6687,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -6679,6 +6702,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "trusted-server-device-fastly" +version = "0.1.0" +dependencies = [ + "fastly", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-geo-fastly" +version = "0.1.0" +dependencies = [ + "error-stack", + "fastly", + "trusted-server-core", +] + [[package]] name = "trusted-server-integration-tests" version = "0.1.0" @@ -6735,6 +6775,42 @@ 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-mtm" +version = "0.1.0" +dependencies = [ + "http", + "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 f5c81ecf4..b1e7d2437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", + "crates/permission-signal/gpc", + "crates/permission-signal/gpp", + "crates/permission-signal/mtm", + "crates/permission-signal/tcf", + "crates/permission-signal/us-privacy", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -120,6 +127,13 @@ toml_edit = "0.23.10" tower = "0.4" tracing = "0.1" 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-mtm = { path = "crates/permission-signal/mtm" } +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/config/permissions/sample.yaml b/config/permissions/sample.yaml new file mode 100644 index 000000000..ade1558f6 --- /dev/null +++ b/config/permissions/sample.yaml @@ -0,0 +1,551 @@ +# The place rules for Trusted Server's permission model, and the jurisdiction +# each place falls under. The `name` at the top is the display name a tool +# such as the permissions inspector shows for this file. +# +# 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 module +# 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 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 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 +# rules a tree of places, from the whole world down to a single region, +# each naming the group and the jurisdiction that apply there +# signals how each session signal (TCF, US-style opt-out) maps onto Data Uses, +# so no signal-to-permission policy lives in the code +# +# The rules tree +# -------------- +# `rules` is one tree. Its top stands for the whole world, countries sit under +# it, and a country's regions sit under that country: +# +# rules: +# group: gdpr-eu +# jurisdiction: gdpr # inherited by every country not overriding it, +# # and the answer when no country resolves +# GB: gdpr-uk # inherits gdpr +# US: +# group: us-opt-out +# jurisdiction: non-regulated +# CA: +# group: us-opt-out +# jurisdiction: us-state +# NY: us-opt-out # inherits non-regulated +# +# Every node names a `group`. Writing a plain group name (`GB: gdpr-uk`) is the +# short form of a node with that group, no jurisdiction of its own, and nothing +# below it. To give a node regions, or a jurisdiction, write it as a block with +# a `group:` line and the rest beside it. A block with no `group:` line is an +# error, because then the node says nothing. +# +# Any node may also name a `jurisdiction`, which is the consent handling that +# applies there. A node that names none inherits the nearest one above it. The +# top of the tree must name both `group` and `jurisdiction`, so inheritance +# always ends somewhere, and the top node is also the answer for a visitor whose +# place the geo module could not resolve at all. +# +# A `jurisdiction` is one of: +# gdpr the EU, EEA and UK regime +# us-state a US state with a comprehensive privacy law. It carries no +# state code, because the region naming it is the state, so +# only a region may name it, never a country or the top +# non-regulated a place with no matching regulation +# unknown declining to name one +# +# Place codes are the codes a geo module returns, matched without regard to +# upper or lower case: +# country ISO 3166-1 alpha-2 (for example FR, US, GB) +# region ISO 3166-2 subdivision, with no country prefix (for example CA for +# California, written under US) +# The Fastly geo module emits these codes directly, and any other module +# must do the same. Those codes are at most three letters long, so a place code +# can never be confused with the reserved words `group`, `jurisdiction` and +# `permissions`. Where the tree puts a code also tells the two DE codes apart, +# since ISO 3166-1 DE is Germany at the country level and ISO 3166-2 DE is +# Delaware under US. +# +# The most specific place wins, and whatever a node does not answer falls back +# to the node above it. A visitor is matched on their region if it is listed, +# otherwise on their country, otherwise on the top of the tree. So the top node +# is also the answer for a country nobody has written a rule for. +# +# A geo module that reports an outright lookup failure is the exception. It +# resolves every permission to the requires-signal floor, and its jurisdiction +# to unknown, rather than to the top of the tree, though no geo module shipped +# today reports one. +# 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, and this repository file is a +# sample modeling common practice rather than legal advice. Service operations +# and legal obligations are granted, the ad-tech Data Uses follow each +# jurisdiction's regime through the `signals` mapping, umbrella Data Uses are +# denied so every grant is named at the specific level, and Data Uses a web +# request never exercises are denied. 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.) +name: Sample (testing and evaluation only) + +groups: + # European Union and EEA, where the model is opt-in, so every modeled ad-tech + # Data Use requires a TCF consent signal. + gdpr-eu: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.frequency_capping: requires_signal + advertising_marketing.negative_targeting: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.third_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + advertising_marketing.personalize.system: requires_signal + functional.personalization: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.ad_delivery_and_targeting: requires_signal + analytics.ad_reporting.ad_viewability: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + analytics.ad_reporting.campaign_insights: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + + # United Kingdom: device storage proceeds without a signal under the reformed + # ePrivacy regime, and the ad-tech Data Uses stay opt-in. + gdpr-uk: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.frequency_capping: requires_signal + advertising_marketing.negative_targeting: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.third_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + advertising_marketing.personalize.system: requires_signal + functional.personalization: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.ad_delivery_and_targeting: requires_signal + analytics.ad_reporting.ad_viewability: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + analytics.ad_reporting.campaign_insights: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + + # United States: an opt-out model, so the modeled ad-tech Data Uses and the + # sale and sharing disclosures are granted until an opt-out signal revokes + # the ones it covers. + us-opt-out: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + advertising_marketing.frequency_capping: granted + advertising_marketing.negative_targeting: granted + advertising_marketing.profiling: granted + advertising_marketing.first_party.targeted: granted + advertising_marketing.third_party.targeted: granted + advertising_marketing.personalize.profiling: granted + advertising_marketing.personalize.content: granted + advertising_marketing.personalize.system: granted + functional.personalization: granted + analytics.ad_reporting.measure_ad_performance: granted + analytics.ad_reporting.ad_delivery_and_targeting: granted + analytics.ad_reporting.ad_viewability: granted + analytics.ad_reporting.content_performance: granted + analytics.ad_reporting.market_research: granted + analytics.ad_reporting.campaign_insights: granted + necessary.operations.improve: granted + select-basic-content: granted + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: granted + disclosure.share: granted + disclosure.third_party_sale: granted + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + +# The place tree. Each node names the `group` that applies there, and may name +# the `jurisdiction` too, either as a plain group name or as a block with a +# `group:` line and the rest beside it. See the header for the full format. +rules: + # The whole world. `group` is the baseline for a visitor whose place the geo + # module could not resolve, and `jurisdiction` is the consent handling for + # that same visitor. Both are required, and every country below inherits the + # jurisdiction unless it names its own. + group: gdpr-eu + jurisdiction: gdpr + + # European Union (27). Each inherits the top node's gdpr jurisdiction. + 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 + + # United Kingdom, which keeps the GDPR jurisdiction under its own regime. + GB: gdpr-uk + + # Australia, where no GDPR or US state law applies. + AU: + group: us-opt-out + jurisdiction: non-regulated + + # United States. Every state follows this country node until a region under it + # says otherwise, so a state appears below only where its own law applies. The + # regions listed are the states with an active comprehensive privacy law, each + # naming `us-state` so the consent gates treat the visitor as covered by it. A + # region may also carry a `permissions:` map to vary single Data Uses on top of + # its group, for example: + # CA: + # group: us-opt-out + # jurisdiction: us-state + # permissions: + # advertising_marketing.first_party.targeted: denied + # Each `permissions` entry maps a Data Use to granted, requires_signal, or + # denied. Or name a stricter group. + US: + group: us-opt-out + jurisdiction: non-regulated + # California. + CA: + group: us-opt-out + jurisdiction: us-state + # Virginia. + VA: + group: us-opt-out + jurisdiction: us-state + # Colorado. + CO: + group: us-opt-out + jurisdiction: us-state + # Connecticut. + CT: + group: us-opt-out + jurisdiction: us-state + # Utah. + UT: + group: us-opt-out + jurisdiction: us-state + # Montana. + MT: + group: us-opt-out + jurisdiction: us-state + # Oregon. + OR: + group: us-opt-out + jurisdiction: us-state + # Texas. + TX: + group: us-opt-out + jurisdiction: us-state + # Florida. + FL: + group: us-opt-out + jurisdiction: us-state + # Delaware. + DE: + group: us-opt-out + jurisdiction: us-state + # Iowa. + IA: + group: us-opt-out + jurisdiction: us-state + # Nebraska. + NE: + group: us-opt-out + jurisdiction: us-state + # New Hampshire. + NH: + group: us-opt-out + jurisdiction: us-state + # New Jersey. + NJ: + group: us-opt-out + jurisdiction: us-state + # Tennessee. + TN: + group: us-opt-out + jurisdiction: us-state + # Minnesota. + MN: + group: us-opt-out + jurisdiction: us-state + # Maryland. + MD: + group: us-opt-out + jurisdiction: us-state + # Indiana. + IN: + group: us-opt-out + jurisdiction: us-state + # Kentucky. + KY: + group: us-opt-out + jurisdiction: us-state + # Rhode Island. + RI: + group: us-opt-out + jurisdiction: us-state + +# 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 +# module 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] module 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 + # grants the Data Uses the TCF module 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 modules are asked in, [permission_signal] module in + # trusted-server.toml, where the last module 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 + # US-style opt-out of sale or sharing. It applies when any listed source is + # set, and its module revokes the Data Uses listed here. Whether that + # revoke stands when a TCF record consents to the same Data Use depends on + # the module 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: + - necessary.operations.storage + - disclosure.sale + - disclosure.share + - disclosure.third_party_sale + - advertising_marketing.first_party.targeted + - advertising_marketing.third_party.targeted + - advertising_marketing.profiling diff --git a/crates/device/README.md b/crates/device/README.md new file mode 100644 index 000000000..865cb9671 --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device modules + +Device-detection module crates live here, one per vendor. The Fastly module +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 signals. Future vendor modules (for example +`crates/device/`) slot in alongside it. + +The built-in default module (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor module via +`build_device_module`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 000000000..c25b4ada1 --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device module exposing opt-in TLS and HTTP/2 signals." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 000000000..8394059ff --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,98 @@ +//! The Fastly device module and host-signal capture. +//! +//! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly +//! Compute. It is selected by `[device] module = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those signals from a live Fastly request +//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can +//! be shared as an injected [`HostSignals`] service that outlives the borrow of +//! the request. Capturing through the SDK is why this crate depends on the +//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the +//! accessors return `None`, so classification degrades to User-Agent only. The +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceModule`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client signals captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 signals once through the Fastly SDK and +/// owns them, so the value can be injected as a [`HostSignals`] service that +/// outlives the borrow of the request it was captured from. Off-host the SDK +/// accessors return `None`, so the signals are simply absent. +#[derive(Debug, Clone, Default)] +pub struct FastlyHostSignals { + ja4: Option, + h2: Option, +} + +impl FastlyHostSignals { + /// Builds host signals from already-captured signal values. + /// + /// Use this when the adapter has read the signals once (for example + /// into the client metadata, or from the trusted internal headers the entry + /// point injects) and wants to share them without another SDK call. + #[must_use] + pub fn new(ja4: Option, h2: Option) -> Self { + Self { ja4, h2 } + } + + /// Captures the TLS JA4 and HTTP/2 signals from a live Fastly request. + #[must_use] + pub fn from_request(req: &FastlyRequest) -> Self { + Self { + ja4: req.get_tls_ja4().map(str::to_string), + h2: req.get_client_h2_fingerprint().map(str::to_string), + } + } +} + +impl HostSignals for FastlyHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } +} + +/// The Fastly device module, opt-in via `[device] module = "fastly"`. +/// +/// Classifies a request with [`DeviceSignals::derive`], which strengthens the +/// User-Agent classification with the host signals. It reads the User-Agent +/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its +/// injected [`HostSignals`], so the browser/bot gate is backed by the live +/// request. +pub struct FastlyDeviceModule { + host_signals: Arc, +} + +impl FastlyDeviceModule { + /// Creates the module with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceModule for FastlyDeviceModule { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..f322c431f --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,18 @@ +# Edge Cookie modules + +Vendor Edge Cookie module crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieModule` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in HMAC module (HMAC over the client IP) ships in +`trusted-server-core` (`ec::module`), so no crate is needed for it. There is +no default module, and a deployment selects one explicitly with +`[ec] module`. + +A module's own settings live in the `[ec.]` table the selector names. +The name is the module's implementation id, the same string its +`EdgeCookieModule::id` returns, unless the table names one with +`implementation = ""`, which lets an operator configure a module under a +name of their own choosing. A module with no settings needs no table. + +This directory is a placeholder until a vendor module is added. diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 000000000..718e87ad3 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..b2077f6c8 --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,20 @@ +# Geo modules + +Geo and IP-intelligence module crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + module for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_module`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo modules (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] module` setting. + +Whatever the source, a module returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other modules feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no +location) both live in `trusted-server-core`, so the default deployment resolves +no location until a module is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 000000000..10c80cafc --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo module backed by the Fastly geolocation API." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 000000000..32ca0a606 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,47 @@ +//! The Fastly host geo module. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo module, +//! injected by the Fastly adapter via `build_geo_module`. With no selector, +//! or `module = "platform"`, this host lookup resolves the location, and +//! `module = "none"` disables geo instead. +//! +//! Like the Fastly device module, this crate calls the Fastly SDK directly, +//! so it depends on the `fastly` crate and builds only for the `wasm32-wasip1` +//! target. The platform-neutral `PlatformGeo` trait and the `DisabledGeo` +//! default both live in `trusted-server-core`. + +use std::net::IpAddr; + +use error_stack::Report; +use fastly::geo::{Geo, geo_lookup}; +use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo}; + +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// The host platform geo module for Fastly Compute. The adapter injects it via +/// `build_geo_module`. With no selector, or `module = "platform"`, it +/// resolves the location, and `module = "none"` disables geo instead. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} diff --git a/crates/permission-signal/gpc/Cargo.toml b/crates/permission-signal/gpc/Cargo.toml new file mode 100644 index 000000000..3bb4981e0 --- /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 module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs new file mode 100644 index 000000000..98e2a81bf --- /dev/null +++ b/crates/permission-signal/gpc/src/lib.rs @@ -0,0 +1,197 @@ +//! Global Privacy Control as a permission signal module. +//! +//! Answers from the `Sec-GPC` request header, which core reads into the +//! consent record's `gpc` flag. Separate from the GPP and US Privacy modules +//! 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 module 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::ConsentContext; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// The `Sec-GPC` request header, Global Privacy Control. +#[derive(Debug, Default, Clone, Copy)] +pub struct GpcModule; + +impl GpcModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for GpcModule { + fn id(&self) -> &'static str { + name() + } + + 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 module 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 + } + + /// The header's one value, when it was sent. There is nothing to + /// decode, so a sent header is always valid. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + } +} + +#[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); + GpcModule::new().signal(permission, &input) + } + + #[test] + fn vouches_for_the_header_only_when_it_was_sent() { + let evidence = OwnedRequestInfo::default(); + assert_eq!( + GpcModule::new().valid_signal(&with_header(true), &evidence), + Some(ValidSignal::new(short(), "gpc", "1")), + "a sent header is the one value it can carry" + ); + assert_eq!( + GpcModule::new().valid_signal(&with_header(false), &evidence), + None, + "no header is no signal" + ); + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GpcModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[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!( + !GpcModule::new().withdraws(Permission::StoreOnDevice, &input), + "a browser setting suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml new file mode 100644 index 000000000..b557f1a79 --- /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 module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs new file mode 100644 index 000000000..212a74ecb --- /dev/null +++ b/crates/permission-signal/gpp/src/lib.rs @@ -0,0 +1,269 @@ +//! The GPP US sale opt-out as a permission signal module. +//! +//! 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 module's scheme. +//! +//! This module 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::ConsentContext; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// A GPP US sale opt-out, read from the `__gpp` string. +#[derive(Debug, Default, Clone, Copy)] +pub struct GppSaleOptOutModule; + +impl GppSaleOptOutModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for GppSaleOptOutModule { + fn id(&self) -> &'static str { + name() + } + + 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 module the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::GppSaleOptOut) + { + return ConsentSignal::Neutral; + } + // A GPP string that arrived and could not be read is treated as the + // opt-out it may have carried, because a preference this module + // cannot see is not the same as no preference. + let unreadable = input.consent.raw_gpp_string.is_some() && input.consent.gpp.is_none(); + let opted_out = input + .consent + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out) + == Some(true); + if (opted_out || unreadable) && 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 + } + + /// The GPP string, when it decoded. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.gpp.as_ref()?; + let raw = consent.raw_gpp_string.as_deref()?; + Some(ValidSignal::new(short(), "gpp", raw)) + } +} + +#[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); + GppSaleOptOutModule::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GppSaleOptOutModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn an_unreadable_gpp_string_is_read_as_the_opt_out_it_may_have_carried() { + let unreadable = ConsentContext { + raw_gpp_string: Some("not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable string refuses what the policy lets an opt-out revoke" + ); + assert_eq!( + answer( + &unreadable, + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "and nothing the policy leaves alone" + ); + } + + #[test] + fn vouches_for_the_gpp_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_gpp_string: Some("DBABMA~CPreadable".to_owned()), + ..with_sale_opt_out(Some(false)) + }; + assert_eq!( + GppSaleOptOutModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "gpp", "DBABMA~CPreadable")), + "a decoded string is vouched for as received, whatever it says" + ); + let unreadable = ConsentContext { + raw_gpp_string: Some("not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + GppSaleOptOutModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not" + ); + } + + #[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!( + !GppSaleOptOutModule::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/mtm/Cargo.toml b/crates/permission-signal/mtm/Cargo.toml new file mode 100644 index 000000000..e590fd5fe --- /dev/null +++ b/crates/permission-signal/mtm/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "trusted-server-permission-signal-mtm" +description = "Model Terms for Marketing, the Preference Management Platform answer, as a permission signal module." +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 } + +[dev-dependencies] +http = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme is 51Degrees' own, so 51Degrees +# holds the crate. +[package.metadata.maintainers] +owner = "51Degrees" +status = "vendor owned" diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs new file mode 100644 index 000000000..ce2a51332 --- /dev/null +++ b/crates/permission-signal/mtm/src/lib.rs @@ -0,0 +1,584 @@ +//! Model Terms for Marketing (MTM) as a permission signal module. +//! +//! The Preference Management Platform (PMP) asks a visitor one question and +//! keeps one of three words, `standard`, `personalized` or `non-marketing`. +//! That word is the whole of the answer, and this module reads it from the +//! first party cookie `__mtm_pref` the PMP writes on the publisher's domain, +//! which exists so that the server can see the answer at all. The PMP keeps +//! its own copy elsewhere and mirrors it here. Absent, the module says +//! nothing and the country and region rules stand, which is clause 5.3 of the +//! Model Terms, under which a preference with no value permits neither kind of +//! marketing. +//! +//! What each word means is worked out from the Model Terms' own text, the +//! versioned document this module declares as the terms the data is +//! available under. Appendix 1 defines standard marketing as content unrelated +//! to browsing history or interactions, and personalized marketing as content +//! related to them, both including the use of cookies, and clause 4.4 permits +//! measurement, optimization and product development subject to the +//! preference. So the Data Uses that depend on browsing history or +//! interactions are the ones `standard` and `personalized` differ on. +//! +//! `non-marketing` changes nothing. The visitor named neither kind of +//! marketing, so this module has no choice to act on and every Data Use is +//! left to the country and region rules, which is clause 5.3's position. +//! +//! The word is still recorded and the terms are still declared, because the +//! question was answered and that is a different fact from never having been +//! asked. Only the permissions are untouched. +//! +//! Where a baseline grants marketing without a signal, the baseline therefore +//! applies. Under the shipped `gdpr-eu` and `gdpr-uk` groups every marketing +//! Data Use requires a signal, so nothing is granted. Under `us-opt-out` they +//! are granted outright, so marketing is permitted. A deployment wanting a +//! decline to bind in an opt-out jurisdiction says so in its own permissions +//! policy, which is where jurisdiction belongs. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use std::sync::OnceLock; + +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::constants::COOKIE_MTM_PREF; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ + ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, +}; +use trusted_server_core::tdl::Tdl; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// The scheme, as a [`ValidSignal`] names it. +pub const SCHEME: &str = "mtm"; + +/// The terms the data is available under when a word is present. Versioned, +/// and never a page that can be edited, because whoever receives the data +/// has to be able to prove what terms were in force when it was sent. +pub const TERMS: &str = "https://m4ow.uk/mtm/2.txt"; + +/// The Data Uses either word grants: storage and the cookies both kinds of +/// marketing include, contextual advertising, and the measurement, +/// optimization and product development clause 4.4 permits. +const UNDER_EITHER_WORD: &[&str] = &[ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual", + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.content_performance", + "analytics.ad_reporting.market_research", + "necessary.operations.improve", + "select-basic-content", +]; + +/// The Data Uses that depend on browsing history or interactions, granted by +/// `personalized` and refused by `standard`. Refused rather than left alone, +/// because the visitor chose against them and that choice has to stand over +/// a country baseline that would otherwise grant them. +const PERSONALIZED_ONLY: &[&str] = &[ + "advertising_marketing.profiling", + "advertising_marketing.first_party.targeted", + "advertising_marketing.personalize.profiling", + "advertising_marketing.personalize.content", +]; + +/// The three words a PMP answer can be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preference { + /// Marketing unrelated to browsing history or interactions. + Standard, + /// Marketing related to browsing history or interactions. + Personalized, + /// No marketing at all. The visitor actively declined it. + NonMarketing, +} + +impl Preference { + /// The word as the PMP wrote it, or `None` for anything else. Any other + /// value is not a word this scheme knows and is treated as absent. + #[must_use] + pub fn parse(word: &str) -> Option { + match word.trim() { + "standard" => Some(Self::Standard), + "personalized" => Some(Self::Personalized), + "non-marketing" => Some(Self::NonMarketing), + _ => None, + } + } + + /// The word as the PMP writes it. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Personalized => "personalized", + Self::NonMarketing => "non-marketing", + } + } +} + +/// The answer on this request, read from the platform's first party cookie. +#[must_use] +pub fn preference(evidence: &dyn RequestInfo) -> Option { + evidence.cookie(COOKIE_MTM_PREF).and_then(Preference::parse) +} + +/// The Model Terms for Marketing, read from the PMP answer. +#[derive(Debug, Default, Clone, Copy)] +pub struct MtmModule; + +impl MtmModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +/// Every Data Use either word says something about, computed once. +fn covered() -> PermissionSet { + static COVERED: OnceLock = OnceLock::new(); + *COVERED.get_or_init(|| { + Permission::all() + .filter(|permission| { + let name = permission.as_str(); + UNDER_EITHER_WORD.contains(&name) || PERSONALIZED_ONLY.contains(&name) + }) + .collect() + }) +} + +impl PermissionSignalModule for MtmModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + let Some(word) = preference(input.evidence) else { + // No answer on the request. Silence, so the country and region + // rules stand, amended by whatever other modules say. + return ConsentSignal::Neutral; + }; + // `non-marketing` changes nothing. The visitor made no marketing choice + // for this module to act on, so whatever the country and region + // rules say stands, which is clause 5.3's position for a preference + // that names neither kind of marketing. + // + // What this means where a baseline grants marketing without a signal, + // stated because it is not obvious and somebody will need it. Under + // the shipped `gdpr-eu` and `gdpr-uk` groups every marketing Data Use + // is `requires_signal`, so nothing is granted and the answer is a + // refusal in effect. Under `us-opt-out` the same Data Uses are + // `granted`, so the baseline applies and marketing is permitted. A + // deployment that wants a decline to bind in an opt-out jurisdiction + // sets that in its own permissions policy, which is where jurisdiction + // belongs, and not in this module, which only says what the two + // marketing words mean. + if word == Preference::NonMarketing { + return ConsentSignal::Neutral; + } + let name = permission.as_str(); + if UNDER_EITHER_WORD.contains(&name) { + return ConsentSignal::Grant; + } + if PERSONALIZED_ONLY.contains(&name) { + return match word { + Preference::Personalized => ConsentSignal::Grant, + // Revoked rather than left alone, because the visitor chose + // against these and a choice must stand over a country + // baseline that would otherwise grant them. + Preference::Standard => ConsentSignal::Revoke, + // Unreachable: handled above. + Preference::NonMarketing => ConsentSignal::Neutral, + }; + } + // A Data Use the Model Terms say nothing about. + ConsentSignal::Neutral + } + + /// Every Data Use either word grants, which is what a page waits for + /// while the PMP question is open. The policy is not consulted, because + /// what the words mean is the Model Terms' own. + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + covered() + } + + /// The word, when one is present. There is nothing else to validate, + /// because a value that is not one of the two words is not an answer. + fn valid_signal( + &self, + _consent: &ConsentContext, + evidence: &dyn RequestInfo, + ) -> Option { + preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + } + + /// The Model Terms, whenever a word is present, because either answer is + /// given under them. Nothing is declared without an answer. + fn tdls(&self, _consent: &ConsentContext, evidence: &dyn RequestInfo) -> Vec { + if preference(evidence).is_none() { + return Vec::new(); + } + Tdl::new(TERMS).into_iter().collect() + } +} + +#[cfg(test)] +mod tests { + use http::HeaderMap; + + use super::*; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permissions::Acquisition; + + fn with_cookie(value: &str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + format!("other=1; {COOKIE_MTM_PREF}={value}; another=2") + .parse() + .expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + fn answer(evidence: &OwnedRequestInfo, permission: Permission) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let input = SignalInput::new(&consent, evidence, &policy, Acquisition::RequiresSignal); + MtmModule::new().signal(permission, &input) + } + + fn named(name: &str) -> Permission { + Permission::all() + .find(|permission| permission.as_str() == name) + .unwrap_or_else(|| panic!("the taxonomy should carry {name}")) + } + + /// The exact bytes the Preference Management Platform writes, generated + /// by running its own code rather than transcribed from a document. Both + /// implementations are checked against the same string so the contract + /// between them is proven rather than agreed twice from two readings. + /// + /// A browser sends only the name and value, so the value is taken the way + /// a browser takes it, which also proves no attribute can leak into it. + const PMP_SET_COOKIE: &[(&str, &str)] = &[ + ( + "standard", + "__mtm_pref=standard; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "personalized", + "__mtm_pref=personalized; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "non-marketing", + "__mtm_pref=non-marketing; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "personalized", + "__mtm_pref=personalized; Path=/; Max-Age=34560000; SameSite=Lax; Domain=.example.com; Secure", + ), + ( + "non-marketing", + "__mtm_pref=non-marketing; Path=/; Max-Age=34560000; SameSite=Lax; Domain=.example.com; Secure", + ), + ]; + + /// The value a browser would send back, taken from a `Set-Cookie` string. + fn value_a_browser_would_send(set_cookie: &str) -> &str { + let pair = set_cookie.split(';').next().unwrap_or_default().trim(); + pair.split_once('=').expect("a cookie is name=value").1 + } + + /// Every string the platform writes must parse to the word it names, with + /// no attribute leaking into the value and no whitespace surviving. + #[test] + fn the_platforms_own_set_cookie_strings_parse_to_the_word_they_carry() { + for (word, set_cookie) in PMP_SET_COOKIE { + let value = value_a_browser_would_send(set_cookie); + assert_eq!( + value, *word, + "the value a browser sends must be the bare word" + ); + assert_eq!( + Preference::parse(value).map(Preference::as_str), + Some(*word), + "the module must accept what the platform writes: {set_cookie}", + ); + } + } + + /// The cookie has to stay readable by the script that owns the question. + /// + /// A browser lets a script neither read nor replace a cookie carrying + /// `HttpOnly` (RFC 6265 section 5.3), so a platform that set one would stop + /// seeing its own answer and the visitor's next choice would be lost with + /// nothing reporting anything wrong. + /// + /// This module only ever reads the cookie, so the rule binds whoever + /// writes it. The assertion is here because these fixtures are the + /// platform's real output, so the day that changes, this is where it shows. + #[test] + fn the_preference_cookie_is_never_httponly() { + for (word, set_cookie) in PMP_SET_COOKIE { + assert!( + !set_cookie.to_ascii_lowercase().contains("httponly"), + "`{word}` must stay readable by the script that owns it: {set_cookie}", + ); + } + } + + /// The permission each word produces, asserted as a whole set rather than + /// row by row, so the contract can be read off one place and any future + /// change to the table has to be made deliberately. + #[test] + fn each_word_produces_the_permissions_the_model_terms_table_states() { + let marketing_only = |word: &str| -> (Vec<&'static str>, Vec<&'static str>) { + let evidence = with_cookie(word); + let mut granted = Vec::new(); + let mut revoked = Vec::new(); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY.iter()) { + match answer(&evidence, named(name)) { + ConsentSignal::Grant => granted.push(*name), + ConsentSignal::Revoke => revoked.push(*name), + // `non-marketing` leaves everything to the baseline, so + // Neutral is the whole of its answer and is counted + // neither way. + ConsentSignal::Neutral => {} + } + } + granted.sort_unstable(); + revoked.sort_unstable(); + (granted, revoked) + }; + + let mut personalized_only_sorted = PERSONALIZED_ONLY.to_vec(); + personalized_only_sorted.sort_unstable(); + + let (standard_grant, standard_revoke) = marketing_only("standard"); + assert_eq!(standard_revoke, personalized_only_sorted); + assert_eq!(standard_grant.len(), UNDER_EITHER_WORD.len()); + + let (personalized_grant, personalized_revoke) = marketing_only("personalized"); + assert!( + personalized_revoke.is_empty(), + "personalized refuses nothing" + ); + assert_eq!( + personalized_grant.len(), + UNDER_EITHER_WORD.len() + PERSONALIZED_ONLY.len(), + ); + + // `non-marketing` moves nothing in either direction, so both lists are + // empty and the country and region rules decide every one of them. + let (decline_grant, decline_revoke) = marketing_only("non-marketing"); + assert!( + decline_grant.is_empty(), + "a decline grants nothing: {decline_grant:?}" + ); + assert!( + decline_revoke.is_empty(), + "a decline refuses nothing: {decline_revoke:?}" + ); + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + MtmModule::new().id(), + "permission-signal.mtm", + "the module is named by its crate folder" + ); + } + + #[test] + fn reads_exactly_the_three_words_and_treats_anything_else_as_absent() { + assert_eq!(Preference::parse("standard"), Some(Preference::Standard)); + assert_eq!( + Preference::parse(" personalized "), + Some(Preference::Personalized) + ); + assert_eq!( + Preference::parse("non-marketing"), + Some(Preference::NonMarketing) + ); + assert_eq!( + Preference::parse("non_marketing"), + None, + "the PMP writes the hyphen, and a near miss is not an answer" + ); + assert_eq!( + Preference::parse("Personalised"), + None, + "not a word the scheme knows" + ); + assert_eq!(Preference::parse(""), None); + assert_eq!( + preference(&with_cookie("standard")), + Some(Preference::Standard) + ); + assert_eq!(preference(&with_cookie("yes")), None); + assert_eq!(preference(&OwnedRequestInfo::default()), None); + } + + #[test] + fn either_word_grants_what_both_kinds_of_marketing_include() { + for name in UNDER_EITHER_WORD { + for word in ["standard", "personalized"] { + assert_eq!( + answer(&with_cookie(word), named(name)), + ConsentSignal::Grant, + "{word} should grant {name}" + ); + } + } + } + + #[test] + fn only_personalized_grants_what_depends_on_browsing_history_and_standard_refuses_it() { + for name in PERSONALIZED_ONLY { + assert_eq!( + answer(&with_cookie("personalized"), named(name)), + ConsentSignal::Grant, + "personalized should grant {name}" + ); + assert_eq!( + answer(&with_cookie("standard"), named(name)), + ConsentSignal::Revoke, + "standard should refuse {name}, because the visitor chose against it" + ); + } + } + + #[test] + fn says_nothing_about_a_data_use_the_terms_do_not_cover() { + let email = named("advertising_marketing.communications.email"); + assert_eq!( + answer(&with_cookie("personalized"), email), + ConsentSignal::Neutral + ); + assert_eq!( + answer(&with_cookie("standard"), email), + ConsentSignal::Neutral + ); + } + + #[test] + fn says_nothing_at_all_without_a_word() { + for permission in Permission::all() { + assert_eq!( + answer(&OwnedRequestInfo::default(), permission), + ConsentSignal::Neutral, + "no answer should leave {} to the country rules", + permission.as_str() + ); + assert_eq!( + answer(&with_cookie("maybe"), permission), + ConsentSignal::Neutral, + "a value that is not a word should be treated as absent" + ); + } + } + + #[test] + fn declares_the_eleven_data_uses_the_words_cover() { + let declared = MtmModule::new().grants(&SignalPolicy::default()); + assert_eq!( + declared.iter().count(), + UNDER_EITHER_WORD.len() + PERSONALIZED_ONLY.len(), + "should declare each covered Data Use once" + ); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY) { + assert!(declared.contains(named(name)), "{name} should be declared"); + } + } + + #[test] + fn vouches_for_the_word_and_declares_the_terms_only_when_one_is_present() { + let consent = ConsentContext::default(); + let module = MtmModule::new(); + assert_eq!( + module.valid_signal(&consent, &with_cookie("standard")), + Some(ValidSignal::new(short(), SCHEME, "standard")) + ); + let declared = module.tdls(&consent, &with_cookie("personalized")); + assert_eq!( + declared.iter().map(Tdl::as_str).collect::>(), + vec![TERMS], + "an answer is given under the Model Terms" + ); + assert_eq!(module.valid_signal(&consent, &with_cookie("maybe")), None); + assert!(module.tdls(&consent, &with_cookie("maybe")).is_empty()); + assert_eq!( + module.valid_signal(&consent, &OwnedRequestInfo::default()), + None + ); + assert!( + module + .tdls(&consent, &OwnedRequestInfo::default()) + .is_empty() + ); + } + + /// `non-marketing` changes nothing, so every Data Use the Model Terms + /// cover comes back Neutral and the country and region rules decide. + #[test] + fn non_marketing_changes_nothing_at_all() { + let evidence = with_cookie("non-marketing"); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY.iter()) { + assert_eq!( + answer(&evidence, named(name)), + ConsentSignal::Neutral, + "non-marketing must leave {name} to the baseline", + ); + } + } + + /// The word is still an answer even though it moves no permission, so it + /// is recorded and the terms are declared. A reader looking at what the + /// appliance decided can then see that the question was answered, which is + /// a different fact from never having been asked. + #[test] + fn non_marketing_is_still_a_recorded_answer_under_the_terms() { + let evidence = with_cookie("non-marketing"); + let consent = ConsentContext::default(); + let module = MtmModule::new(); + + let signal = module + .valid_signal(&consent, &evidence) + .expect("a decline is a valid answer"); + assert_eq!(signal.value, "non-marketing"); + assert_eq!( + module.tdls(&consent, &evidence).len(), + 1, + "the answer was given under the Model Terms whatever it said", + ); + } + + #[test] + fn neither_word_is_a_withdrawal() { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let evidence = with_cookie("standard"); + let input = SignalInput::new(&consent, &evidence, &policy, Acquisition::RequiresSignal); + assert!( + !MtmModule::new().withdraws(Permission::StoreOnDevice, &input), + "a standard answer refuses personalization and leaves an issued identifier alone" + ); + } +} diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml new file mode 100644 index 000000000..91d8c4da6 --- /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 module, with the purpose mapping." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs new file mode 100644 index 000000000..ccdfdfbfe --- /dev/null +++ b/crates/permission-signal/tcf/src/lib.rs @@ -0,0 +1,482 @@ +//! IAB TCF v2 as a permission signal module. +//! +//! 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 module 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 module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +mod mapping; + +use std::sync::OnceLock; + +pub use mapping::purpose_for; + +#[cfg(test)] +use trusted_server_core::consent::types::TcfConsent; +use trusted_server_core::consent::{ConsentContext, effective_tcf}; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ + ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, +}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// 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 TcfModule; + +impl TcfModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for TcfModule { + fn id(&self) -> &'static str { + name() + } + + 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; + }; + if unreadable(input.consent) { + // A TC string arrived and could not be read. The visitor expressed + // a preference this module cannot see, which is not the same as + // no preference, so it fails closed on everything it maps rather + // than leaving the place baseline standing. An expired record is + // not this case, expiry being its own explicit state. + return ConsentSignal::Revoke; + } + 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 + } + } + + /// 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(); + } + mapped() + } + + /// The standalone TC string, when it decoded and has not expired. A record + /// carried inside a GPP string is the GPP string's, which the GPP + /// module vouches for. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.tcf.as_ref()?; + let raw = consent.raw_tc_string.as_deref()?; + Some(ValidSignal::new(short(), "tcf", raw)) + } + + /// 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 modules 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()) + } +} + +/// Every Data Use a purpose maps to, computed once rather than on every +/// request, because the mapping is a constant of this crate. +fn mapped() -> PermissionSet { + static MAPPED: OnceLock = OnceLock::new(); + *MAPPED.get_or_init(|| { + Permission::all() + .filter(|permission| mapping::purpose_for(*permission).is_some()) + .collect() + }) +} + +/// Whether a TC string arrived that could not be decoded, expiry aside. +fn unreadable(consent: &ConsentContext) -> bool { + consent.raw_tc_string.is_some() && consent.tcf.is_none() && !consent.expired +} + +#[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); + TcfModule::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); + TcfModule::new().withdraws(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + TcfModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[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 an_unreadable_tc_string_is_this_modules_refusal_and_an_expired_one_is_not() { + // A string arrived that could not be decoded. That is a preference + // this module cannot see, so it refuses what it maps, silently, + // and says nothing about a Data Use no purpose covers. + let unreadable = ConsentContext { + raw_tc_string: Some("not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable record refuses the Data Uses this scheme covers" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert_eq!( + answer(&unreadable, shipped_policy(), email), + ConsentSignal::Neutral, + "and says nothing about a Data Use no purpose maps to" + ); + assert_eq!( + answer( + &unreadable, + &SignalPolicy::default(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a silenced scheme says nothing, readable or not" + ); + let expired = ConsentContext { + raw_tc_string: Some("CPold".to_owned()), + expired: true, + ..ConsentContext::default() + }; + assert_eq!( + answer(&expired, shipped_policy(), Permission::StoreOnDevice), + ConsentSignal::Neutral, + "expiry is its own explicit state and not an unreadable record" + ); + } + + #[test] + fn vouches_for_the_standalone_tc_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_tc_string: Some("CPreadable".to_owned()), + ..with_record(&[1]) + }; + assert_eq!( + TcfModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "tcf", "CPreadable")), + "a decoded record vouches for the string as received" + ); + let unreadable = ConsentContext { + raw_tc_string: Some("not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + TcfModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not vouched for" + ); + let expired = ConsentContext { + raw_tc_string: Some("CPold".to_owned()), + expired: true, + ..ConsentContext::default() + }; + assert_eq!( + TcfModule::new().valid_signal(&expired, &evidence), + None, + "an expired record is not one this module uses, so it is not vouched for" + ); + assert_eq!( + TcfModule::new().valid_signal(&ConsentContext::default(), &evidence), + None, + "and no record is no signal" + ); + } + + #[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 = TcfModule::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!( + TcfModule::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 + // 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 module reads what core resolved + // rather than the wire, so it sees that section too. + let consent = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(tcf_with_purposes(&[4])), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + assert_eq!( + answer( + &consent, + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Grant, + "the EU section's consent to Purpose 4 grants targeted advertising" + ); + assert!( + withdraws(&consent, shipped_policy(), Permission::StoreOnDevice), + "and its refusal of Purpose 1 withdraws storage" + ); + } +} diff --git a/crates/permission-signal/tcf/src/mapping.rs b/crates/permission-signal/tcf/src/mapping.rs new file mode 100644 index 000000000..07bda1c89 --- /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 module answers silence for it rather than a refusal. +/// +/// Compared on the identifier string, so a lookup is at most twenty string +/// comparisons. Resolving each identifier back to a [`Permission`] first would +/// scan the whole taxonomy per row, and this runs for every permission on +/// every request. +#[must_use] +pub fn purpose_for(permission: Permission) -> Option { + let id = permission.as_str(); + PURPOSES + .iter() + .find_map(|(purpose, uses)| uses.contains(&id).then_some(*purpose)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_identifier_in_the_table_is_a_real_data_use() { + // The failure this guards is a typo silently disabling a purpose. A + // name that resolves to nothing would make the purpose grant nothing, + // and no test asserting one specific mapping would notice the ones it + // does not name. + let mut unknown = Vec::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if Permission::from_identifier(id).is_none() { + unknown.push(format!("purpose {purpose}: {id}")); + } + } + } + assert!( + unknown.is_empty(), + "these Data Use identifiers are not in the taxonomy: {unknown:?}" + ); + } + + #[test] + fn no_data_use_is_granted_by_two_purposes() { + // The policy parser used to refuse this as a duplicate. With the table + // in code the check moves here, so a purpose cannot be silently + // shadowed by an earlier row. + let mut seen = std::collections::BTreeMap::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if let Some(first) = seen.insert(*id, *purpose) { + panic!("{id} is granted by purpose {first} and again by purpose {purpose}"); + } + } + } + } + + #[test] + fn the_purposes_the_policy_block_used_to_declare_still_map() { + // The two mappings the old policy parser test pinned, now pinned here. + assert_eq!( + purpose_for(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + purpose_for(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + } + + #[test] + fn a_purpose_granting_several_uses_is_found_from_each_of_them() { + // Purpose 4 grants two Data Uses, and both must resolve back to it. + let first = Permission::from_identifier("advertising_marketing.first_party.targeted") + .expect("should be a known Data Use"); + let third = Permission::from_identifier("advertising_marketing.third_party.targeted") + .expect("should be a known Data Use"); + assert_eq!( + purpose_for(first), + Some(4), + "the first-party Data Use is Purpose 4" + ); + assert_eq!(purpose_for(third), Some(4), "and so is the third-party one"); + } + + #[test] + fn a_data_use_no_purpose_grants_maps_to_nothing() { + // A sale disclosure is a Data Use the taxonomy carries and no TCF + // purpose grants. Silence rather than a refusal is the contract, and + // it starts here. + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + purpose_for(sale), + None, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } +} diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml new file mode 100644 index 000000000..349f5bd4e --- /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 module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs new file mode 100644 index 000000000..7005258ca --- /dev/null +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -0,0 +1,271 @@ +//! The US Privacy string sale opt-out as a permission signal module. +//! +//! 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 module sees the result +//! the same way, because it reads the record and not the wire. +//! +//! This module 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::{ConsentContext, PrivacyFlag}; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// A US Privacy string sale opt-out, read from `us_privacy`. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsPrivacyModule; + +impl UsPrivacyModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for UsPrivacyModule { + fn id(&self) -> &'static str { + name() + } + + 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 module the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::UsPrivacyOptOut) + { + return ConsentSignal::Neutral; + } + // A US Privacy string that arrived and could not be read is treated + // as the opt-out it may have carried, because a preference this + // module cannot see is not the same as no preference. + let unreadable = + input.consent.raw_us_privacy.is_some() && input.consent.us_privacy.is_none(); + let opted_out = input + .consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + if (opted_out || unreadable) && 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 + } + + /// The US Privacy string, when it decoded. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.us_privacy.as_ref()?; + let raw = consent.raw_us_privacy.as_deref()?; + Some(ValidSignal::new(short(), "us_privacy", raw)) + } +} + +#[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); + UsPrivacyModule::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + UsPrivacyModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn an_unreadable_us_privacy_string_is_read_as_the_opt_out_it_may_have_carried() { + let unreadable = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable string refuses what the policy lets an opt-out revoke" + ); + assert_eq!( + answer( + &unreadable, + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "and nothing the policy leaves alone" + ); + } + + #[test] + fn vouches_for_the_us_privacy_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_us_privacy: Some("1YNN".to_owned()), + ..with_sale_flag(PrivacyFlag::No) + }; + assert_eq!( + UsPrivacyModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "us_privacy", "1YNN")), + "a decoded string is vouched for as received, whatever it says" + ); + let unreadable = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + UsPrivacyModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not" + ); + } + + #[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!( + !UsPrivacyModule::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 94ad02170..c2723d5c9 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,6 +29,11 @@ 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-mtm = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } [dev-dependencies] trusted-server-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index caba714d6..8f7cff2ac 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -18,6 +18,7 @@ use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::ensure_module_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -51,9 +52,37 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The permission signal modules `[permission-signal] modules` 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_modules: + Arc<[Arc]>, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } +/// The permission signal modules 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 module 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_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + /// Build the application state, loading settings and constructing all per-application components. /// /// # Errors @@ -77,8 +106,9 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -89,24 +119,50 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: reject a module selection this adapter can never + // supply, once, before any request is served. A caller supplying its own + // `RuntimeServices` may already have resolved a module, so the check is + // given whatever those services carry, which is what `EcContext` sees per + // request. + // + // This adapter checks rather than keeps what the check resolved, unlike the + // Fastly, Cloudflare and Spin adapters, because it is a long-lived process + // whose application state is built once at start-up while theirs is rebuilt + // for every request. With no services supplied it threads no module, so + // `EcContext` resolves the selection itself on every request, building a + // fresh built-in module that reads no request data. It supplies no host + // signals either, so the `host_signals` argument is `None`. + ensure_module_available( + &settings.ec, + None, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + permission_signal_modules, services, })) } impl AppState { fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { - self.services - .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + self.services.clone().unwrap_or_else(|| { + build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + }) } } @@ -179,25 +235,31 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the top of the +/// `permissions.yaml` rules tree, 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 that top node 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 +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(&state.settings, req, services) } // --------------------------------------------------------------------------- @@ -244,7 +306,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -505,7 +567,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -524,7 +586,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -715,3 +777,90 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true + "#; + + /// Builds application state directly, bypassing the composition root's + /// startup check, so the per-request behavior can be exercised with a + /// selection the adapter cannot supply. + fn state_with_uninjected_module() -> AppState { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) + .expect("should build orchestrator"); + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + // These tests exercise the Edge Cookie module path, and a + // request with no signal module resolves at the place baseline. + permission_signal_modules: Arc::default(), + // This test drives the per-request path, which builds its services + // from the request context. + services: None, + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let state = state_with_uninjected_module(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_modules); + let req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..6516d6f9d 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -193,7 +193,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index e2713f47f..54a64bf1e 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -527,7 +527,13 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// The generic runtime KV slot uses /// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); @@ -569,9 +575,16 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] module selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_module( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) + // The signal modules 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_modules(Arc::clone(permission_signal_modules)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs new file mode 100644 index 000000000..ad0c87683 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -0,0 +1,670 @@ +//! The four shipped signal modules assembled together, as a deployment +//! runs them. +//! +//! Each module crate tests its own scheme in isolation, in its own unit +//! tests. What is tested here is what only shows when multiple modules +//! 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 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` module 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::{ + PermissionSignalModule, build_permission_signal_modules, +}; +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::GpcModule; +use trusted_server_permission_signal_gpp::GppSaleOptOutModule; +use trusted_server_permission_signal_mtm::MtmModule; +use trusted_server_permission_signal_tcf::TcfModule; +use trusted_server_permission_signal_us_privacy::UsPrivacyModule; + +/// The four IAB modules an adapter offers, in the default order. MTM is +/// offered after them and is tested with them below. +fn all_four() -> Vec> { + vec![ + Arc::new(GpcModule::new()), + Arc::new(GppSaleOptOutModule::new()), + Arc::new(UsPrivacyModule::new()), + Arc::new(TcfModule::new()), + ] +} + +/// All five, as an adapter offers them. +fn all_five() -> Vec> { + let mut modules = all_four(); + modules.push(Arc::new(MtmModule::new())); + modules +} + +/// Settings naming these identifiers in `[permission-signal] modules`. +fn settings_naming(names: &[&str]) -> Settings { + let mut settings = Settings::default(); + settings.permission_signal.modules = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + settings +} + +/// The modules a deployment gets from naming these identifiers in +/// `[permission-signal] modules`, through the same entry point an adapter's +/// composition root uses. +fn configured(names: &[&str]) -> Arc<[Arc]> { + build_permission_signal_modules(&settings_naming(names), &all_four()) + .expect("should select modules this build offers") +} + +/// Every module 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(|module| trusted_server_core::permission_signal::short_name(module.as_ref())) + .filter(|name| *name != excluded) + .collect(); + configured(&names) +} + +fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::default() +} + +fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + modules: &[Arc], +) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, modules) +} + +/// 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 modules run. +// ---------------------------------------------------------------------- + +#[test] +fn the_documented_names_select_every_shipped_module_in_order() { + // The names the guide and the example configuration list, which must be + // the names the shipped crates answer to within the section. + let documented = ["gpc", "gpp", "us-privacy", "tcf"]; + let selected: Vec<&str> = configured(&documented) + .iter() + .map(|module| trusted_server_core::permission_signal::short_name(module.as_ref())) + .collect(); + assert_eq!( + selected, documented, + "each documented name selects the shipped module it names, in the order written" + ); +} + +#[test] +fn an_old_name_is_refused_naming_the_names_available() { + for old in ["gpp_sale_opt_out", "us_privacy"] { + let Err(error) = build_permission_signal_modules(&settings_naming(&[old]), &all_four()) + else { + panic!("should refuse the old name `{old}`"); + }; + let message = format!("{error:?}"); + assert!( + message.contains(&format!("`{old}` is not available in this build")) + && message.contains("Available modules are gpc, gpp, 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 + // 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_module_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 module 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 module 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 modules 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 module should suppress, whatever the others say" + ); +} + +// ---------------------------------------------------------------------- +// Opt-out and prompt precedence. +// +// The modules are asked in order and each amends what the ones before it +// settled, so a later module 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 module 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 modules = configured(&["tcf", "gpc"]); + let state = assembled(&consent, GeoStatus::Located(&geo), &modules); + 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_module_to_be_running() { + // The withdrawal is TCF's answer, so a deployment that removed the TCF + // module 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 modules. +// ---------------------------------------------------------------------- + +#[test] +fn an_unreadable_tcf_record_is_the_tcf_modules_refusal() { + 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" + ); + assert!( + state.signals().is_empty(), + "and the string is not a valid signal, so nothing downstream sees it" + ); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("tcf")); + assert!( + state.is_set(Permission::StoreOnDevice), + "a scheme that does not run answers nothing, readable or not" + ); +} + +#[test] +fn an_unreadable_gpp_string_is_the_gpp_modules_opt_out_and_the_order_decides() { + // The GPP module reads its own unreadable string as the opt-out it + // may have carried. Asked after it, a readable TCF record consenting to + // storage amends that, because the order is the policy. Nothing in + // core answers ahead of the modules. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_tc_string: Some("CPreadable".to_owned()), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_but("tcf")) + .is_set(Permission::StoreOnDevice), + "without TCF the GPP module's reading of its unreadable string stands" + ); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "asked last, the readable TCF record amends the GPP module's answer" + ); + let schemes: Vec<&str> = state.signals().iter().map(|s| s.scheme).collect(); + assert_eq!( + schemes, + vec!["tcf"], + "only the readable string is vouched for, so only it goes any further" + ); +} + +#[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" + ); +} + +#[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 module 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" + ); +} + +#[test] +fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() { + use trusted_server_core::constants::COOKIE_MTM_PREF; + + 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 evidence = |word: &str| { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "cookie", + format!("{COOKIE_MTM_PREF}={word}") + .parse() + .expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + }; + let standard = assemble_permissions( + &ConsentContext::default(), + &evidence("standard"), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + standard.is_set(Permission::StoreOnDevice) + && standard.is_set(Permission::SelectBasicAds) + && !standard.is_set(Permission::SelectPersonalisedAds) + && !standard.is_awaited(Permission::SelectPersonalisedAds), + "standard grants storage and contextual advertising and refuses targeting" + ); + assert_eq!( + standard.signals(), + &[trusted_server_core::permissions::ValidSignal::new( + "mtm", "mtm", "standard" + )], + "the word is the valid signal, as received" + ); + assert_eq!( + standard + .tdls() + .iter() + .map(trusted_server_core::tdl::Tdl::as_str) + .collect::>(), + vec!["https://m4ow.uk/mtm/2.txt"], + "an answer is given under the versioned Model Terms" + ); + let personalized = assemble_permissions( + &ConsentContext::default(), + &evidence("personalized"), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + personalized.is_set(Permission::SelectPersonalisedAds), + "personalized grants targeting too" + ); + let unanswered = assemble_permissions( + &ConsentContext::default(), + &no_evidence(), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + unanswered.is_awaited(Permission::SelectPersonalisedAds) && unanswered.tdls().is_empty(), + "with no answer the question is still open and no terms are declared" + ); +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 7126b2a71..aaff51e1a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,7 +33,13 @@ fn test_settings() -> trusted_server_core::settings::Settings { proxy_secret = "integration-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings") @@ -868,6 +874,62 @@ async fn first_party_proxy_rebuild_is_routed() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and the Axum dev +/// server injects no vendor Edge Cookie module, so only the composition root +/// can catch it. Without the startup check the deployment would come up and +/// answer every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 2ce969ef7..b5545562a 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -31,6 +31,11 @@ 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-mtm = { 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 87b9567e7..4a4228890 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -22,6 +22,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -74,6 +75,23 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` 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_modules: + Arc<[Arc]>, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } @@ -191,8 +209,9 @@ fn cloudflare_config_envelope( /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -203,24 +222,73 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals, so that argument is `None`, and injects no + // vendor Edge Cookie module of its own, so the only injected module is + // one a caller put into the services it supplied. + let ec_module = build_reusable_module( + &settings.ec, + None, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_module, + permission_signal_modules, services, })) } +/// The permission signal modules 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 module 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_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + impl AppState { + /// Builds the per-request services, carrying the Edge Cookie module the + /// composition root already resolved so the request path does not resolve + /// `[ec] module` a second time. Nothing is carried when the composition + /// root found nothing safe to keep, and the request path resolves for + /// itself. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + .unwrap_or_else(|| { + build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + }) + .with_resolved_ec_module(self.ec_module.clone()) } } @@ -231,24 +299,30 @@ impl AppState { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the Workers `cf` object when deployed. A malformed -/// consent string is logged and falls back to the default (fail-closed) context -/// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the top of the +/// `permissions.yaml` rules tree, 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 +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(settings, req, services) } // --------------------------------------------------------------------------- @@ -553,7 +627,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -658,7 +738,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -722,7 +802,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -775,8 +855,72 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. The + /// settings are parsed directly, bypassing the composition root's startup + /// check, so the per-request behavior can be exercised with a selection + /// the adapter cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved module 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, &Arc::default()); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" @@ -793,6 +937,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -855,6 +1002,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -899,6 +1049,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-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..4527fd4ab 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -209,7 +209,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 94616b203..ceed17e83 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -594,7 +594,13 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -629,7 +635,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] module selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_module(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -637,7 +645,11 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) + // The signal modules 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_modules(Arc::clone(permission_signal_modules)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -654,15 +666,23 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R /// Reads Cloudflare geo headers injected by the Workers runtime. /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, -/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// `cf-iplatitude`, `cf-iplongitude` and `cf-region-code` require an Enterprise +/// plan and the visitor-location managed transform. Absent or unparseable +/// values default to empty strings or `0.0`. Country code `XX` (Cloudflare's +/// "unknown" sentinel) is treated as absent. +/// +/// The region is the ISO 3166-2 subdivision code from `cf-region-code`, for +/// example `CA`, and not the subdivision name from `cf-region`, because the +/// region nodes of the `permissions.yaml` rules tree, including the US states +/// that carry `jurisdiction: us-state`, are written as two-letter codes and a +/// name would never match one. struct CloudflareGeo { country: String, city: String, continent: String, latitude: f64, longitude: f64, + region: Option, } impl PlatformGeo for CloudflareGeo { @@ -677,7 +697,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -711,12 +731,19 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); + let region = headers + .get("cf-region-code") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); CloudflareGeo { country, city, continent, latitude, longitude, + region, } } @@ -793,6 +820,77 @@ mod tests { RequestContext::new(req, PathParams::default()) } + /// Builds a request context carrying multiple headers at once. + fn make_ctx_with_headers(headers: &[(&str, &str)]) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + for (name, value) in headers { + builder = builder.header( + *name, + HeaderValue::from_str(value).expect("should parse test header value"), + ); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn a_us_state_visitor_reaches_the_us_state_jurisdiction_and_its_opt_out() { + // The region header has to reach the privacy outcome. + // `detect_jurisdiction` reaches a US state node of the policy tree + // only when the country is `US` and a region is present. With no + // region a US visitor falls through to the country node's + // `NonRegulated`, where `allows_ec_creation` returns true without + // reading `ctx.gpc`, so a Sec-GPC opt-out would be ignored. + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region.as_deref(), + Some("CA"), + "the ISO 3166-2 subdivision code should reach the geo info" + ); + + let jurisdiction = + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo)); + assert_eq!( + jurisdiction, + trusted_server_core::consent::jurisdiction::Jurisdiction::UsState("CA".to_owned()), + "a Californian visitor should reach the US state jurisdiction" + ); + + // Reaching that jurisdiction is what this adapter is responsible for, + // and it is the switch every downstream opt-out hangs off. A Sec-GPC + // signal, a GPP US sale opt-out and a US Privacy opt-out are all + // consulted on the US state branch and none of them are consulted on + // the unregulated one, so a visitor who never reaches the US state + // jurisdiction has every one of those signals ignored. Which gate reads + // the jurisdiction is core's business and changes across this stack, so + // it is core that tests the reading. + + // Without the region header nothing can place the visitor in a state, + // so the jurisdiction is the unregulated one and the same opt-out is + // ignored. That is the behavior this fix removes for any deployment + // whose plan supplies the header. + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region, None, + "no region header should mean no region, not an invented one" + ); + assert_eq!( + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo)), + trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, + "with no region a US visitor cannot be placed in a privacy state" + ); + } + fn make_ctx_without_header() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index f385bf529..dee7ea488 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,7 +36,13 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -84,7 +90,13 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -676,6 +688,62 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and this adapter +/// injects no vendor Edge Cookie module, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 710523766..c561e4941 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -35,6 +35,13 @@ log-fastly = { workspace = true } serde = { 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-mtm = { 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 } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index fbcd5735d..a07159bd7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -112,10 +112,11 @@ 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; +use trusted_server_core::ec::module::request_module; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -124,9 +125,12 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; +use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, }; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -183,6 +187,21 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + pub(crate) ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` 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_modules: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -209,15 +228,46 @@ pub(crate) fn load_settings_from_config_store( ) } +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. pub(crate) fn build_state_from_settings( settings: Settings, ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request, and keep what the resolution produced so the + // request path does not resolve the same settings again. This adapter + // injects no vendor Edge Cookie module, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + // + // This adapter injects host signals on every request, so a startup instance + // with no captured signals answers the only question the check asks, + // which is whether the service exists at all. That same emptiness is why + // `build_reusable_module` hands back nothing for a module built from + // those signals, leaving it to be resolved per request against the + // signals that request actually carried. + let ec_module = build_reusable_module( + &settings.ec, + Some(Arc::new(FastlyHostSignals::default())), + None, + )?; + let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -228,9 +278,31 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_module, + permission_signal_modules, })) } +/// The permission signal modules 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 module 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_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + fn warn_if_certificate_check_disabled(settings: &Settings) { if !settings.proxy.certificate_check { log::warn!( @@ -265,7 +337,24 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - RuntimeServices::builder() + // The TLS JA4 and HTTP/2 signals arrive as trusted internal headers + // injected by the entry point. They build the host-signal service a + // host-signal module reads. Fastly always supplies the capability, so the + // service is always set even when a request carried no signal. + let tls_ja4 = ctx + .request() + .headers() + .get("x-ts-tls-ja4") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let h2_fingerprint = ctx + .request() + .headers() + .get("x-ts-h2-fingerprint") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) @@ -276,10 +365,27 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_module( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) - .build() + // The signal modules 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_modules(Arc::clone(&state.permission_signal_modules)) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); + + // Hand every request the module resolved at the composition root, so the + // request path reuses that instance instead of resolving `[ec] module` + // again. Nothing is set for a deployment that selects no module, or one + // whose module is built from this request's own host signals, and both + // are resolved on the request path instead. + match state.ec_module.clone() { + Some(module) => builder.resolved_ec_module(module).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -401,7 +507,7 @@ fn build_ec_request_state( req: &Request, ) -> EcRequestState { let device_signals = device_signals_for(req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", @@ -414,16 +520,8 @@ 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); // Orphan-recovery eligibility is intentionally left false here. @@ -437,18 +535,21 @@ fn build_ec_request_state( } 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 { @@ -487,11 +588,15 @@ enum PreRoute { /// mutations are applied to `req` so the routed handler observes them; response /// effects are returned for the entry point to apply after EC finalization. A /// filter that responds (e.g. a `DataDome` challenge) short-circuits routing. +/// +/// `permissions` carries the state resolved when the EC context was built, so +/// every filter reads the same permissions as the rest of the request. async fn run_pre_route_filters( state: &AppState, services: &RuntimeServices, req: &mut Request, geo_info: Option<&GeoInfo>, + permissions: Option<&PermissionState>, ) -> PreRoute { match state .registry @@ -500,6 +605,7 @@ async fn run_pre_route_filters( services, req, geo_info, + permissions, }) .await { @@ -577,7 +683,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected module decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let module = request_module(&state.settings.ec, &services)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, module.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -605,13 +716,20 @@ async fn execute_named( )); } - let effects = - match run_pre_route_filters(&state, &services, &mut req, ec.geo_info.as_ref()).await { - PreRoute::ShortCircuit { response, effects } => { - return Ok(attach_dispatch_extensions(response, ec, effects)); - } - PreRoute::Continue { effects } => effects, - }; + let effects = match run_pre_route_filters( + &state, + &services, + &mut req, + ec.geo_info.as_ref(), + Some(ec.ec_context.permissions()), + ) + .await + { + PreRoute::ShortCircuit { response, effects } => { + return Ok(attach_dispatch_extensions(response, ec, effects)); + } + PreRoute::Continue { effects } => effects, + }; let response = run_named_route(&state, &services, req, handler, &mut ec) .await @@ -730,14 +848,18 @@ async fn run_named_route( /// response finalization. fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); let result = crate::require_identity_graph(&state.settings).and_then(|kv| { let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) + // A partner echoes back an identifier the deployment's own module + // created, so validation and KV normalization are dispatched through + // that module rather than the built-in HMAC grammar. + let module = request_module(&state.settings.ec, services)?; + handle_batch_sync(&kv, &partner_registry, &limiter, module.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); @@ -790,7 +912,14 @@ async fn dispatch_fallback( // Pre-route integration request filters (DataDome protection, etc.) run // before the route-type decision, matching legacy `route_request` ordering. - let effects = match run_pre_route_filters(state, services, &mut req, ec.geo_info.as_ref()).await + let effects = match run_pre_route_filters( + state, + services, + &mut req, + ec.geo_info.as_ref(), + Some(ec.ec_context.permissions()), + ) + .await { PreRoute::ShortCircuit { response, effects } => { return attach_dispatch_extensions(response, ec, effects); @@ -847,7 +976,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Run the server-side auction with the configured creative- @@ -1291,7 +1420,7 @@ impl TrustedServerApp { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), - Arc::new(FastlyPlatformGeo), + build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); @@ -1521,8 +1650,14 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1596,6 +1731,11 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // Resolved the same way the composition root resolves it, so this + // router behaves like a served one. + let ec_module = + trusted_server_core::ec::module::build_reusable_module(&settings.ec, None, None) + .expect("should resolve the Edge Cookie module selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1604,6 +1744,10 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_module, + // These tests exercise routing, and a request with no signal + // module resolves at the place baseline. + permission_signal_modules: Arc::default(), }); TrustedServerApp::routes_for_state(&state) } @@ -2022,7 +2166,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse production-shaped settings"); @@ -2695,8 +2845,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -2966,6 +3122,12 @@ mod tests { [ec] passphrase = "test-secret-key-32-bytes-minimum" + # The deprecated passphrase migrates to the hmac module, so + # single-jurisdiction operation is acknowledged because no + # geo module is selected. + [geo] + assume_single_jurisdiction = true + [auction] enabled = true providers = {} @@ -3122,8 +3284,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + 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 b9f3b86be..4cb151df6 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -7,14 +7,16 @@ use edgezero_core::app::Hooks as _; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; +use edgezero_core::http::{ + HeaderMap, HeaderValue, Request as HttpRequest, Response as HttpResponse, header, +}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceModule, DeviceSignals, build_device_module}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -22,11 +24,13 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::platform::build_geo_module; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceModule, FastlyHostSignals}; mod app; mod backend; @@ -450,7 +454,42 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req, resolved_client_ip); let client_ip = client_info.client_ip; - let device_signals = derive_device_signals(&req); + + // Strip and re-inject the TLS JA4 and HTTP/2 signals from the + // authoritative Fastly SDK values, under the same trust model, so the + // EdgeZero app path can build the host-signal service from these internal + // headers (the SDK accessors return real values only on the live client + // request, not on a request rebuilt from EdgeZero HTTP types). + req.remove_header("x-ts-tls-ja4"); + req.remove_header("x-ts-h2-fingerprint"); + // Take ownership before setting: unlike the static TLS protocol/cipher + // names, these accessors borrow the request, which would otherwise conflict + // with the mutable `set_header`. + if let Some(ja4) = req.get_tls_ja4().map(str::to_string) { + req.set_header("x-ts-tls-ja4", ja4); + } + if let Some(h2) = req.get_client_h2_fingerprint().map(str::to_string) { + req.set_header("x-ts-h2-fingerprint", h2); + } + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + // Reuse the settings snapshot already loaded for the app state rather than + // fetching and validating the config-store blob a second time per request. + let device_signals = match settings_snapshot.as_deref() { + Some(settings) => derive_device_signals(settings, &req), + None => { + log::warn!( + "EdgeZero device signals: settings unavailable, using UA-only classification" + ); + DeviceSignals::derive_ua_only(req.get_header_str("user-agent").unwrap_or("")) + } + }; // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such @@ -576,8 +615,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] module selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -823,16 +866,33 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { - let ua = req.get_header_str("user-agent").unwrap_or(""); - let ja4 = req.get_tls_ja4(); - let h2_fp = req.get_client_h2_fingerprint(); - - DeviceSignals::derive(ua, ja4, h2_fp) +/// The modules read request data from injected services. Device +/// classification reads only the User-Agent, borrowed here through a +/// `BorrowedRequestInfo`, unless `fastly` is selected, in which case the Fastly +/// module also reads the TLS and HTTP/2 signals captured into a +/// [`FastlyHostSignals`]. The Fastly entry point still reads those TLS and +/// HTTP/2 signals on every request to build the host-signal service and client +/// info, so the capture is not conditional on the module selection. +pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> DeviceSignals { + let mut headers = HeaderMap::new(); + if let Some(value) = req + .get_header_str(header::USER_AGENT.as_str()) + .and_then(|user_agent| HeaderValue::from_str(user_agent).ok()) + { + headers.insert(header::USER_AGENT, value); + } + let client_ip = req + .get_client_ip_addr() + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); + build_device_module(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceModule::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] @@ -858,7 +918,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1069,6 +1135,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + 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 283f16255..4956ebc1d 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -319,7 +319,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index d0f4ee9d0..4584c8e4f 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -3,7 +3,6 @@ use bytes::Bytes; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use std::io::Read as _; use std::net::IpAddr; @@ -11,12 +10,11 @@ use std::net::IpAddr; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, - PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, - PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, + PlatformConfigStore, PlatformError, PlatformHttpClient, PlatformHttpRequest, + PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, + PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -654,33 +652,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo module lives in its own crate, +/// `trusted-server-geo-fastly`, as every module implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's call sites refer to it +/// through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { let mut values = req.get_header_all(name); diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index 228106047..c0db16529 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -538,6 +538,9 @@ mod tests { origin_url = "https://origin.test-publisher.example" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] passphrase = "test-secret-key-32-bytes-minimum" "#, diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 81eeb9430..93f891ecf 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,6 +32,11 @@ 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-mtm = { 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 b8e8a2492..2faf549a2 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -24,6 +24,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -67,15 +68,37 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` 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_modules: + Arc<[Arc]>, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } /// Build the application state, loading settings and constructing all per-application components. /// +/// Settings are read from the platform config store at run time, the same way +/// the Fastly and Axum adapters read them, so an operator publishes one with +/// `ts config push` and the deployed component picks it up. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialize. fn build_state() -> Result, Report> { let settings = load_startup_settings()?; build_state_with_settings(settings) @@ -119,8 +142,9 @@ fn load_startup_settings() -> Result> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -131,24 +155,73 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals, so that argument is `None`, and injects no + // vendor Edge Cookie module of its own, so the only injected module is + // one a caller put into the services it supplied. + let ec_module = build_reusable_module( + &settings.ec, + None, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_module, + permission_signal_modules, services, })) } +/// The permission signal modules 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 module 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_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + impl AppState { + /// Builds the per-request services, carrying the Edge Cookie module the + /// composition root already resolved so the request path does not resolve + /// `[ec] module` a second time. Nothing is carried when the composition + /// root found nothing safe to keep, and the request path resolves for + /// itself. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + .unwrap_or_else(|| { + build_runtime_services(ctx, &self.settings, &self.permission_signal_modules) + }) + .with_resolved_ec_module(self.ec_module.clone()) } } @@ -409,25 +482,31 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless -/// the request carries TCF consent. A malformed consent string is logged and -/// falls back to the default (fail-closed) context rather than being silently -/// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the top of the +/// `permissions.yaml` rules tree, 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 that top node +/// 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 +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(settings, req, services) } fn cache_purge_not_supported() -> Response { @@ -680,8 +759,13 @@ fn build_router(state: &Arc) -> RouterService { } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it - // closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // closed for consented users. When identity cannot be + // established at all, answer with an error rather than running + // the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -711,7 +795,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than re-running the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -834,7 +924,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -975,6 +1071,9 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; fn multi_provider_settings() -> Settings { @@ -993,6 +1092,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -1071,6 +1173,90 @@ mod tests { ); } + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // Every other test enters through the `routes_with_settings` parity + // seam, so this is the one that calls `build_state` itself. There is + // no Spin runtime under `cargo test`, so there are no component + // variables to read and this cannot return `Ok` here. Its settings + // come from the config store alone, so the failure has to be the + // absence of a config store, and never a configuration compiled into + // the binary, such as a template's placeholder password. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. The + /// settings are parsed directly, bypassing the composition root's startup + /// check, so the per-request behavior can be exercised with a selection + /// the adapter cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved module 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, &Arc::default()); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..38bb48cf2 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -236,7 +236,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b1ced0f70..2b38228cb 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -704,7 +704,13 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -733,7 +739,17 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] module selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_module( + settings, + Arc::new(NullGeo), + )) + // The signal modules 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_modules(Arc::clone(permission_signal_modules)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1040,7 +1056,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); + let services = build_runtime_services( + &ctx, + &trusted_server_core::settings::Settings::default(), + &Arc::default(), + ); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 613832b05..2bfc8f9f4 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,7 +35,13 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -974,6 +980,62 @@ async fn admin_deactivate_key_auth_fail_returns_401() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and this adapter +/// injects no vendor Edge Cookie module, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 3dbf41df3..b1203896a 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -41,6 +41,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/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 19aa0b82f..e0d5caa6f 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -13,6 +13,7 @@ fn make_config() -> HtmlProcessorConfig { 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, diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index ab3585e3d..a25eaafb9 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,10 +171,11 @@ 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. - // Owned so the identity-graph snapshot can be stored back on `ec_context` - // below without holding a borrow of it across the mutation. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted, + // being storage plus personalized-ad selection, the same pair that gates + // EIDs. Owned so the identity-graph snapshot can be stored back on + // `ec_context` below without holding a borrow of it across the mutation. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value().map(str::to_owned) } else { None @@ -282,8 +283,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` @@ -303,8 +305,13 @@ pub async fn handle_auction( // EC and both KV and partner stores are available. Gate the read on a // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. + // The row is read under the owning module's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -340,10 +347,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 @@ -451,7 +457,13 @@ pub(crate) fn resolve_auction_eids( let ec_id = ec_context.ec_value()?; - let Some(entry) = snapshot.entry_for(ec_id) else { + // Callers read the snapshot under the identity-graph key, the owning + // module's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -624,6 +636,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -671,13 +684,25 @@ 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::default(), + ec_allowed, + ) + } + + /// A context in a non-regulated jurisdiction with the permission gate open, + /// for tests whose subject is dispatch or identity reuse rather than the + /// jurisdiction gate. + fn make_non_regulated_ec_context(ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), ConsentContext { - jurisdiction, + jurisdiction: Jurisdiction::NonRegulated, ..ConsentContext::default() }, + true, ) } @@ -729,7 +754,7 @@ mod tests { graph.create(&ec_id, &live).expect("should seed live row"); lookups.store(0, std::sync::atomic::Ordering::Relaxed); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut ec_context = make_non_regulated_ec_context(Some(&ec_id)); let req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") @@ -797,6 +822,84 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_loads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning module's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a module whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = make_non_regulated_ec_context(Some(CANONICAL_COOKIE_VALUE)) + .with_module_for_test(Arc::new(CanonicalizingModule)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; @@ -915,7 +1018,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let mut ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -964,7 +1071,7 @@ mod tests { orchestrator.register_provider(Arc::new(PanicOnBidProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1027,7 +1134,7 @@ mod tests { let orchestrator = AuctionOrchestrator::from_plan(plan, None); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1089,7 +1196,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 mut 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 mut ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -1286,7 +1395,7 @@ mod tests { fn resolve_auction_eids_returns_empty_without_snapshot() { 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(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1298,7 +1407,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_without_registry() { 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(&EcKvSnapshot::NotRead, None, &ec_context); assert!( @@ -1311,7 +1420,7 @@ mod tests { fn resolve_auction_eids_returns_none_when_consent_denied() { 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(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1323,7 +1432,11 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_when_no_ec() { 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(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1336,7 +1449,7 @@ mod tests { fn resolve_auction_eids_returns_empty_on_kv_miss() { 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 snapshot = EcKvSnapshot::Failed { ec_id: ec_id.clone(), diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 79f100e2f..ce5fdd135 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,8 +11,9 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::module::{HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -32,7 +33,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +use crate::settings::{ + AssetOriginAuth, Ec, IntegrationConfig, MODULE_IMPLEMENTATION_KEY, Settings, +}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -117,6 +120,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_module_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +135,101 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie module blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + let labeled = ec + .module_blocks + .hmac_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HMAC_MODULE_KEY) + .chain( + ec.module_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_MODULE_KEY), + ); + for name in labeled { + let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) + else { + continue; + }; + block_errors.errors_mut().remove("passphrase"); + if block_errors.errors().is_empty() { + ec_errors.errors_mut().remove(name); + } + } + // An `ec` entry holding nothing would keep the whole result an error, the + // same reason `EdgeZero` prunes emptied containers after its own removals. + let ec_is_empty = ec_errors.errors().is_empty(); + if ec_is_empty { + errors.errors_mut().remove("ec"); + } +} + +impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { + /// The passphrase of every Edge Cookie module block that configures a + /// module built into core under a label. + /// + /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) + /// lists the passphrases of the `[ec.hmac]` and `[ec.host_signals]` + /// blocks, the one path each of those modules' blocks has when its name + /// is its implementation. The same module under a label of the + /// operator's choosing holds that secret at `ec.