diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..bc8c20d71 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/.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 3b7189204..cb7cbcb25 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 provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) + permission-signal/ # permission signal provider crates, one per scheme (gpc, gpp, tcf, us-privacy); core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -59,7 +64,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 @@ -145,6 +152,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 @@ -272,12 +297,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 provider 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 provider that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated provider **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every provider and every integration sees +all the evidence available for a request, including host signals such as the TLS +JA4 and HTTP/2 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 provider's reach is working against the +architecture, not protecting it. + +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -294,6 +355,42 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- | +| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] provider` (an ordered list) | None, and with no provider every permission stays at its country and region baseline | `crates/permission-signal/` | + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 signals) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no + host signal calls. +- **Providers are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie provider + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor providers share a backend (for example a vendor's Edge Cookie, + geo, and device provider on one cloud pipeline) they share a single call per + request rather than calling independently. Give a provider the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -327,7 +424,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and the `[permission_signal] provider` list) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/Cargo.lock b/Cargo.lock index 311597aae..18b8e8e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4567,6 +4567,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5356,6 +5369,10 @@ dependencies = [ "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5375,6 +5392,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "worker", ] @@ -5400,6 +5421,12 @@ 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-tcf", + "trusted-server-permission-signal-us-privacy", "url", "urlencoding", ] @@ -5424,6 +5451,10 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -5501,6 +5532,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5515,6 +5547,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" @@ -5570,6 +5619,34 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5655,6 +5732,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index dfd94d0c4..944c2781a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", + "crates/permission-signal/gpc", + "crates/permission-signal/gpp", + "crates/permission-signal/tcf", + "crates/permission-signal/us-privacy", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -96,6 +102,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" sha2 = "0.10.9" simple_logger = "5" spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } @@ -110,6 +117,12 @@ toml = "1.1" toml_edit = "0.23.10" tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } +trusted-server-device-fastly = { path = "crates/device/fastly" } +trusted-server-geo-fastly = { path = "crates/geo/fastly" } +trusted-server-permission-signal-gpc = { path = "crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "crates/permission-signal/us-privacy" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/config/permissions/sample.yaml b/config/permissions/sample.yaml new file mode 100644 index 000000000..165fe80d9 --- /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 provider +# declares should be stated in the industry's own terms rather than in a +# vocabulary this project invented, so a reader can check it against the +# taxonomy rather than against us. +# +# This file is 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 provider 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 provider 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 provider emits these codes directly, and any other provider +# 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 provider 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 provider 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 + # provider 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 +# provider crate, crates/permission-signal/tcf, so a deployment that runs no +# TCF carries no table of another scheme's numbers. Which schemes run at all, +# and in what order, is [permission_signal] provider in trusted-server.toml. +signals: + # A present TCF v2 record (a standalone TC string, or the EU TCF section of a + # GPP string). With authoritative true, the record's consent to a purpose + # grants the Data Uses the TCF provider maps to that purpose, and its refusal + # revokes them, whereas with authoritative false the record is ignored. + # The flag governs only whether the record answers. Whether its answer + # stands over an opt-out signal below, or the opt-out over it, is the order + # the providers are asked in, [permission_signal] provider in + # trusted-server.toml, where the last provider with an opinion decides. The + # default order asks Global Privacy Control first, being a browser setting + # with no interface of its own, and the schemes carrying a choice someone + # 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 provider revokes the Data Uses listed here. Whether that + # revoke stands when a TCF record consents to the same Data Use depends on + # the provider order, see above. The list below models the US state opt-out + # scope, being sale, sharing, targeted advertising, and device storage as + # the conservative reading, so an opted-out visitor gets no Edge Cookie + # written and no identifier shared, while contextual advertising and + # measurement continue after an opt-out. A deployer can widen the list, or + # write `revokes: all` to drop every granted Data Use. An opt-out suppresses + # use for the request and is never a destructive withdrawal of an + # already-issued identifier. + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: + - 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..3d2ca5f9f --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device providers + +Device-detection provider crates live here, one per vendor. The Fastly provider +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 signals. Future vendor providers (for example +`crates/device/`) slot in alongside it. + +The built-in default provider (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor provider via +`build_device_provider`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 000000000..8d4b61d48 --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } + +# 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..9b36ea369 --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,98 @@ +//! The Fastly device provider and host-signal capture. +//! +//! [`FastlyDeviceProvider`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly +//! Compute. It is selected by `[device] provider = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those 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 [`DeviceProvider`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client 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 provider, opt-in via `[device] provider = "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 FastlyDeviceProvider { + host_signals: Arc, +} + +impl FastlyDeviceProvider { + /// Creates the provider with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceProvider for FastlyDeviceProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..0c8d6be59 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,18 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in HMAC provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. There is +no default provider, and a deployment selects one explicitly with +`[ec] provider`. + +A provider's own settings live in the `[ec.]` table the selector names. +The name is the provider's implementation id, the same string its +`EdgeCookieProvider::id` returns, unless the table names one with +`implementation = ""`, which lets an operator configure a provider under a +name of their own choosing. A provider with no settings needs no table. + +This directory is a placeholder until a vendor provider is added. diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 000000000..718e87ad3 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..f3c5d11fb --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,20 @@ +# Geo providers + +Geo and IP-intelligence provider crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + provider for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_provider`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo providers (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] provider` setting. + +Whatever the source, a provider returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other providers feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no +location) both live in `trusted-server-core`, so the default deployment resolves +no location until a provider is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 000000000..79d3acf62 --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo provider backed by the Fastly geolocation API." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } + +# 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..5128fa7c6 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,47 @@ +//! The Fastly host geo provider. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo provider, +//! injected by the Fastly adapter via `build_geo_provider`. With no selector, +//! or `provider = "platform"`, this host lookup resolves the location, and +//! `provider = "none"` disables geo instead. +//! +//! Like the Fastly device provider, 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 provider for Fastly Compute. The adapter injects it via +/// `build_geo_provider`. With no selector, or `provider = "platform"`, it +/// resolves the location, and `provider = "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..1e64caf2d --- /dev/null +++ b/crates/permission-signal/gpc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpc" +description = "Global Privacy Control as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs new file mode 100644 index 000000000..e2ef6b643 --- /dev/null +++ b/crates/permission-signal/gpc/src/lib.rs @@ -0,0 +1,158 @@ +//! Global Privacy Control as a permission signal provider. +//! +//! Answers from the `Sec-GPC` request header, which core reads into the +//! consent record's `gpc` flag. Separate from the GPP and US Privacy providers +//! so that a publisher who does not act on Global Privacy Control can leave +//! this one out of the configured list without also losing the other two +//! opt-outs. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "gpc"; + +/// The `Sec-GPC` request header, Global Privacy Control. +#[derive(Debug, Default, Clone, Copy)] +pub struct GpcProvider; + +impl GpcProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GpcProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input.policy.opt_out_sources().contains(&OptOutSource::Gpc) { + return ConsentSignal::Neutral; + } + if input.consent.gpc && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy lists this scheme as an opt-out and revokes device + /// storage on it, and leaves ad measurement alone. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_header(set: bool) -> ConsentContext { + ConsentContext { + gpc: set, + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GpcProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GpcProvider::new().id(), + ID, + "the provider 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!( + !GpcProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a browser setting suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml new file mode 100644 index 000000000..ecbcc7194 --- /dev/null +++ b/crates/permission-signal/gpp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-gpp" +description = "The GPP US sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs new file mode 100644 index 000000000..e77e66e69 --- /dev/null +++ b/crates/permission-signal/gpp/src/lib.rs @@ -0,0 +1,195 @@ +//! The GPP US sale opt-out as a permission signal provider. +//! +//! Answers from the US sale opt-out carried in the `__gpp` string, which core +//! decodes into the consent record. It says nothing about the EU TCF section a +//! GPP string may also carry, because that is the TCF provider's scheme. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "gpp_sale_opt_out"; + +/// A GPP US sale opt-out, read from the `__gpp` string. +#[derive(Debug, Default, Clone, Copy)] +pub struct GppSaleOptOutProvider; + +impl GppSaleOptOutProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for GppSaleOptOutProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::GppSaleOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out) + == Some(true); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_opt_out(value: Option) -> ConsentContext { + ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: value, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GppSaleOptOutProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GppSaleOptOutProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(false)), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a section present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_opt_out(None), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a section carrying no sale flag is silence" + ); + } + + #[test] + fn is_silent_when_no_gpp_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_opt_out(Some(true)); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GppSaleOptOutProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml new file mode 100644 index 000000000..f57ca510c --- /dev/null +++ b/crates/permission-signal/tcf/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-tcf" +description = "IAB TCF v2 as a permission signal provider, with the purpose mapping." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs new file mode 100644 index 000000000..a73c5473e --- /dev/null +++ b/crates/permission-signal/tcf/src/lib.rs @@ -0,0 +1,306 @@ +//! IAB TCF v2 as a permission signal provider. +//! +//! Answers from the decoded TCF record for the purposes this crate maps to +//! each permission, and is the one place that knows what a TCF purpose is. +//! Core decodes the TC string, keeps the record against the Edge Cookie +//! identifier, expires it by age and resolves it against a GPP EU section, and +//! this provider reads what that pipeline produced rather than decoding the +//! cookie a second time. Reading the wire directly would silently skip the +//! cached record on a returning visitor and the expiry rule, and answer +//! differently from every other reader of the same request. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +mod mapping; + +pub use mapping::purpose_for; + +use trusted_server_core::consent::effective_tcf; +#[cfg(test)] +use trusted_server_core::consent::types::TcfConsent; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "tcf"; + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is this crate's, in +/// [`purpose_for`], so core carries no table of another scheme's numbers. A +/// permission no purpose maps to gets silence, not a refusal. +#[derive(Debug, Default, Clone, Copy)] +pub struct TcfProvider; + +impl TcfProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for TcfProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy still says whether a TCF record answers for this + // deployment at all. What it no longer says is which purpose grants + // which permission, because that is this scheme's own knowledge. + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(purpose) = mapping::purpose_for(permission) else { + // TCF has nothing to say about this Data Use, so it says nothing. + return ConsentSignal::Neutral; + }; + let Some(record) = effective_tcf(input.consent) else { + // No TCF record on the request. Silence, not refusal, because + // reading an absent scheme as a refusal would revoke on every + // request that did not carry it. + return ConsentSignal::Neutral; + }; + if record.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal. Reading it + // as silence would leave the country baseline standing and grant + // what they declined. + ConsentSignal::Revoke + } + } + + /// Only a TCF record refusing storage withdraws, because only TCF records + /// a visitor declining the very signal storage depended on. A US-style + /// opt-out suppresses use for the request and never destroys an identifier, + /// so the other providers leave this at its default. + /// + /// Whether the refusal is destructive at all is core's to decide from the + /// jurisdiction's storage baseline, which is why this answers the narrow + /// question only. It does not consult `tcf_authoritative`, matching the + /// rule as it stood before the seam, where a record refusing storage + /// withdrew whether or not the policy let the record grant anything. + fn withdraws(&self, permission: Permission, input: &SignalInput<'_>) -> bool { + if permission != Permission::StoreOnDevice { + return false; + } + effective_tcf(input.consent).is_some_and(|record| !record.has_storage_consent()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The shipped policy, under which a TCF record answers. + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + fn with_record(consented: &[usize]) -> ConsentContext { + ConsentContext { + tcf: Some(tcf_with_purposes(consented)), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().signal(permission, &input) + } + + fn withdraws(consent: &ConsentContext, policy: &SignalPolicy, permission: Permission) -> bool { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfProvider::new().withdraws(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + TcfProvider::new().id(), + ID, + "the provider 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 a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { + // The default policy declares no TCF block, so the record does not + // answer for the deployment. Withdrawal is the narrower, destructive + // question and keeps the rule it had before the seam, which did not + // consult the flag. + let silenced = SignalPolicy::default(); + assert!( + !silenced.tcf_authoritative(), + "the fixture must not be authoritative" + ); + assert_eq!( + answer( + &with_record(&[4]), + &silenced, + Permission::SelectPersonalisedAds + ), + ConsentSignal::Neutral, + "a record the policy does not let answer stays silent" + ); + assert!( + withdraws(&with_record(&[4]), &silenced, Permission::StoreOnDevice), + "but a record refusing storage still withdraws, as it did before the seam" + ); + } + + #[test] + fn withdraws_only_for_storage_and_only_when_refused() { + assert!( + withdraws( + &with_record(&[4]), + shipped_policy(), + Permission::StoreOnDevice + ), + "refusing Purpose 1 withdraws storage" + ); + assert!( + !withdraws( + &with_record(&[1]), + shipped_policy(), + Permission::StoreOnDevice + ), + "consenting to Purpose 1 is not a withdrawal" + ); + assert!( + !withdraws( + &with_record(&[]), + shipped_policy(), + Permission::SelectPersonalisedAds + ), + "no other permission is ever withdrawn, refused or not" + ); + assert!( + !withdraws( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + "and no record is never a withdrawal" + ); + } + + #[test] + fn reads_the_eu_section_of_a_gpp_string_when_there_is_no_standalone_record() { + // Core resolves a GPP string's EU TCF section as the effective record + // when no TC string arrived, and this provider reads what core resolved + // rather than the wire, so it sees that section too. + let consent = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(tcf_with_purposes(&[4])), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + assert_eq!( + answer( + &consent, + shipped_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Grant, + "the EU section's consent to Purpose 4 grants targeted advertising" + ); + assert!( + withdraws(&consent, shipped_policy(), Permission::StoreOnDevice), + "and its refusal of Purpose 1 withdraws storage" + ); + } +} diff --git a/crates/permission-signal/tcf/src/mapping.rs b/crates/permission-signal/tcf/src/mapping.rs new file mode 100644 index 000000000..f9cdea45a --- /dev/null +++ b/crates/permission-signal/tcf/src/mapping.rs @@ -0,0 +1,172 @@ +//! Which Data Use each TCF purpose grants. +//! +//! This table lives here, in the crate for the scheme it belongs to, rather +//! than in the policy file core reads. Core does not know what a TCF purpose +//! is, and a deployment that runs no TCF at all should not carry a table of +//! another scheme's numbers in its configuration. +//! +//! It was moved verbatim from the `signals.tcf.purposes` block of the sample +//! policy, so behavior is unchanged for a deployment that never edited that +//! block. A deployment that had edited it now changes this crate instead. +//! +//! # Where this should eventually come from +//! +//! The IAB Privacy Taxonomy is adding a `tcf` column. When that is finalized +//! it becomes the single source for this mapping and the table below is +//! replaced by reading it, rather than being maintained by hand. Until then +//! this is the authority for this crate. + +use trusted_server_core::permissions::Permission; + +/// A TCF purpose number and the Data Use identifiers it grants. +/// +/// Identifiers rather than [`Permission`] values, so the table reads the same +/// as the policy block it came from and can be checked against the taxonomy by +/// eye. +const PURPOSES: &[(u8, &[&str])] = &[ + (1, &["necessary.operations.storage"]), + ( + 2, + &[ + "advertising_marketing.first_party.contextual", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + ], + ), + (3, &["advertising_marketing.profiling"]), + ( + 4, + &[ + "advertising_marketing.first_party.targeted", + "advertising_marketing.third_party.targeted", + ], + ), + (5, &["advertising_marketing.personalize.profiling"]), + ( + 6, + &[ + "advertising_marketing.personalize.content", + "advertising_marketing.personalize.system", + "functional.personalization", + ], + ), + ( + 7, + &[ + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_viewability", + ], + ), + (8, &["analytics.ad_reporting.content_performance"]), + ( + 9, + &[ + "analytics.ad_reporting.market_research", + "analytics.ad_reporting.campaign_insights", + ], + ), + (10, &["necessary.operations.improve"]), + (11, &["select-basic-content"]), +]; + +/// The TCF purpose that grants `permission`, or `None` when no purpose does. +/// +/// A permission no purpose maps to is one TCF has nothing to say about, and +/// the provider answers silence for it rather than a refusal. +/// +/// Compared on the identifier string, so a lookup is at most twenty string +/// comparisons. Resolving each identifier back to a [`Permission`] first would +/// scan the whole taxonomy per row, and this runs for every permission on +/// every request. +#[must_use] +pub fn purpose_for(permission: Permission) -> Option { + let id = permission.as_str(); + PURPOSES + .iter() + .find_map(|(purpose, uses)| uses.contains(&id).then_some(*purpose)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_identifier_in_the_table_is_a_real_data_use() { + // The failure this guards is a typo silently disabling a purpose. A + // name that resolves to nothing would make the purpose grant nothing, + // and no test asserting one specific mapping would notice the ones it + // does not name. + let mut unknown = Vec::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if Permission::from_identifier(id).is_none() { + unknown.push(format!("purpose {purpose}: {id}")); + } + } + } + assert!( + unknown.is_empty(), + "these Data Use identifiers are not in the taxonomy: {unknown:?}" + ); + } + + #[test] + fn no_data_use_is_granted_by_two_purposes() { + // The policy parser used to refuse this as a duplicate. With the table + // in code the check moves here, so a purpose cannot be silently + // shadowed by an earlier row. + let mut seen = std::collections::BTreeMap::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if let Some(first) = seen.insert(*id, *purpose) { + panic!("{id} is granted by purpose {first} and again by purpose {purpose}"); + } + } + } + } + + #[test] + fn the_purposes_the_policy_block_used_to_declare_still_map() { + // The two mappings the old policy parser test pinned, now pinned here. + assert_eq!( + purpose_for(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + purpose_for(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + } + + #[test] + fn a_purpose_granting_several_uses_is_found_from_each_of_them() { + // Purpose 4 grants two Data Uses, and both must resolve back to it. + let first = Permission::from_identifier("advertising_marketing.first_party.targeted") + .expect("should be a known Data Use"); + let third = Permission::from_identifier("advertising_marketing.third_party.targeted") + .expect("should be a known Data Use"); + assert_eq!( + purpose_for(first), + Some(4), + "the first-party Data Use is Purpose 4" + ); + assert_eq!(purpose_for(third), Some(4), "and so is the third-party one"); + } + + #[test] + fn a_data_use_no_purpose_grants_maps_to_nothing() { + // A sale disclosure is a Data Use the taxonomy carries and no TCF + // purpose grants. Silence rather than a refusal is the contract, and + // it starts here. + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + purpose_for(sale), + None, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } +} diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml new file mode 100644 index 000000000..db84aadb1 --- /dev/null +++ b/crates/permission-signal/us-privacy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "trusted-server-permission-signal-us-privacy" +description = "The US Privacy string sale opt-out as a permission signal provider." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs new file mode 100644 index 000000000..80bc49a1a --- /dev/null +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -0,0 +1,197 @@ +//! The US Privacy string sale opt-out as a permission signal provider. +//! +//! Answers from the sale opt-out carried in the four character `us_privacy` +//! string, which core decodes into the consent record. Core also constructs +//! that string from a Global Privacy Control header in a US state when the +//! deployment's consent settings say to, and this provider sees the result +//! the same way, because it reads the record and not the wire. +//! +//! This provider lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::PrivacyFlag; +use trusted_server_core::permission_signal::{PermissionSignalProvider, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission}; + +/// The stable identifier this provider answers to in `[permission_signal]` +/// `provider`, in logs, and when a peer consults it. +pub const ID: &str = "us_privacy"; + +/// A US Privacy string sale opt-out, read from `us_privacy`. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsPrivacyProvider; + +impl UsPrivacyProvider { + /// A new provider. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalProvider for UsPrivacyProvider { + fn id(&self) -> &'static str { + ID + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a provider the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::UsPrivacyOptOut) + { + return ConsentSignal::Neutral; + } + let opted_out = input + .consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + if opted_out && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::UsPrivacy; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn shipped_policy() -> &'static SignalPolicy { + PermissionMaps::standard().signals() + } + + fn with_sale_flag(opt_out_sale: PrivacyFlag) -> ConsentContext { + ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + UsPrivacyProvider::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + UsPrivacyProvider::new().id(), + ID, + "the provider answers to the identifier configuration names" + ); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the shipped policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + shipped_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the shipped revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::No), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a string present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::NotApplicable), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a string saying the flag does not apply is silence" + ); + } + + #[test] + fn is_silent_when_no_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_flag(PrivacyFlag::Yes); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + shipped_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !UsPrivacyProvider::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..b28eda1bf 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,6 +29,10 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } [dev-dependencies] axum = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 38776eb95..ecfdc4a6b 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::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -51,6 +52,31 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The permission signal providers `[permission_signal] provider` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, +} + +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] } /// Build the application state, loading settings and constructing all per-application components. @@ -76,20 +102,41 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. The Axum dev server injects + // no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + // + // 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. It injects and threads no provider, so `EcContext` + // resolves the selection itself on every request, building a fresh built-in + // provider that reads no request data. It supplies no host signals either, + // so the `host_signals` argument is `None`. + ensure_provider_available(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + permission_signal_providers, })) } @@ -142,7 +189,8 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -162,25 +210,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 provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(&state.settings, req, services) } // --------------------------------------------------------------------------- @@ -227,7 +281,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(), @@ -463,7 +517,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, @@ -482,7 +536,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(), @@ -649,3 +703,88 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction + # operation is acknowledged because no geo provider 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_provider() -> AppState { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + 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 provider path, and a + // request with no signal provider resolves at the place baseline. + permission_signal_providers: Arc::default(), + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let state = state_with_uninjected_provider(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = + build_runtime_services(&ctx, &state.settings, &state.permission_signal_providers); + let req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..08f974723 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] + provider = "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 7dcdd53d8..76ad63982 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -525,7 +525,13 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route /// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, + permission_signal_providers: &Arc< + [Arc], + >, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!( @@ -570,9 +576,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] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs new file mode 100644 index 000000000..44d06e35d --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -0,0 +1,514 @@ +//! The four shipped signal providers assembled together, as a deployment +//! runs them. +//! +//! Each provider crate tests its own scheme in isolation, in its own unit +//! tests. What is tested here is what only shows when multiple providers +//! run in order through core's assembly: an answer to a prompt applying +//! over an opt-out, one opt-out standing when another is removed, a scheme +//! left off the list not running at all, and withdrawal being TCF's alone +//! and scoped to the place. This sits in the Axum adapter's tests because +//! it is the first crate that links all four, and core deliberately links +//! none. The names a deployment writes in configuration are checked here for +//! the same reason, against the identifiers the real crates answer to. +//! +//! The consent records here are built by hand, so nothing in core's consent +//! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy +//! opt-out from a Global Privacy Control header in a US state when the consent +//! settings say to, and the `us_privacy` provider then acts on it, which is +//! why removing `gpc` from the list alone does not make that header inert. + +use std::sync::Arc; + +use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; +use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::{ + PermissionSignalProvider, build_permission_signal_providers, +}; +use trusted_server_core::permissions::{Permission, PermissionState}; +use trusted_server_core::platform::GeoInfo; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpc::GpcProvider; +use trusted_server_permission_signal_gpp::GppSaleOptOutProvider; +use trusted_server_permission_signal_tcf::TcfProvider; +use trusted_server_permission_signal_us_privacy::UsPrivacyProvider; + +/// The four providers an adapter offers, in the default order. +fn all_four() -> Vec> { + vec![ + Arc::new(GpcProvider::new()), + Arc::new(GppSaleOptOutProvider::new()), + Arc::new(UsPrivacyProvider::new()), + Arc::new(TcfProvider::new()), + ] +} + +/// Settings naming these identifiers in `[permission_signal] provider`. +fn settings_naming(names: &[&str]) -> Settings { + let mut settings = Settings::default(); + settings.permission_signal.provider = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + settings +} + +/// The providers a deployment gets from naming these identifiers in +/// `[permission_signal] provider`, through the same entry point an adapter's +/// composition root uses. +fn configured(names: &[&str]) -> Arc<[Arc]> { + build_permission_signal_providers(&settings_naming(names), &all_four()) + .expect("should select providers this build offers") +} + +/// Every provider except the one named, in the default order, as a +/// publisher removes one from configuration. +fn all_but(excluded: &str) -> Arc<[Arc]> { + let names: Vec<&str> = all_four() + .iter() + .map(|provider| provider.id()) + .filter(|id| *id != excluded) + .collect(); + configured(&names) +} + +fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::default() +} + +fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + providers: &[Arc], +) -> PermissionState { + assemble_permissions(consent, &no_evidence(), geo, providers) +} + +/// Builds a minimal decoded TCF record consenting to the given 1-indexed +/// purposes, with everything else refused. +fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } +} + +fn us_privacy_opted_out() -> UsPrivacy { + UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + } +} + +fn gpp_sale_opted_out() -> GppConsent { + GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + } +} + +/// A US opt-out state, where the baseline grants storage without a signal, so +/// a revoke is observable as a drop and a refusal is never a withdrawal. +fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } +} + +// ---------------------------------------------------------------------- +// Which providers run. +// ---------------------------------------------------------------------- + +#[test] +fn the_documented_names_select_every_shipped_provider_in_order() { + // The names the guide and the example configuration list, which must be + // the identifiers the shipped crates answer to. + let documented = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"]; + let selected: Vec<&str> = configured(&documented) + .iter() + .map(|provider| provider.id()) + .collect(); + assert_eq!( + selected, documented, + "each documented name selects the shipped provider it names, in the order written" + ); +} + +#[test] +fn an_old_hyphenated_name_is_refused_naming_the_names_available() { + for old in ["gpp-sale-opt-out", "us-privacy"] { + let Err(error) = build_permission_signal_providers(&settings_naming(&[old]), &all_four()) + else { + panic!("should refuse the hyphenated name `{old}`"); + }; + let message = format!("{error:?}"); + assert!( + message.contains(&format!("`{old}` is not available in this build")) + && message + .contains("Available providers are gpc, gpp_sale_opt_out, us_privacy, tcf"), + "the refusal names the old name and the names to write instead: {message}" + ); + } +} + +#[test] +fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline, because the map granted + // these purposes and Global Privacy Control revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); +} + +#[test] +fn a_provider_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every provider running, the header takes storage away" + ); + + let pruned = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the provider that read the header then does not run" + ); +} + +#[test] +fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate providers rather than one. + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); +} + +#[test] +fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(UsPrivacy { + opt_out_sale: PrivacyFlag::No, + ..us_privacy_opted_out() + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out provider should suppress, whatever the others say" + ); +} + +// ---------------------------------------------------------------------- +// Opt-out and prompt precedence. +// +// The providers are asked in order and each amends what the ones before it +// settled, so a later provider can amend an opt-out. The default order asks +// Global Privacy Control first, being a browser setting with no interface of +// its own, and the schemes carrying a choice someone made through an +// interface after, which is why an answer given at a prompt amends the +// header the visitor arrived with. A deployment wanting the opposite puts +// the provider it wants to win last. +// ---------------------------------------------------------------------- + +#[test] +fn a_prompt_answer_applies_over_a_gpc_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn the_opt_out_wins_when_a_deployment_puts_it_last() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let providers = configured(&["tcf", "gpc"]); + let state = assembled(&consent, GeoStatus::Located(&geo), &providers); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the same request, with the order reversed in configuration, lets the header win" + ); +} + +// ---------------------------------------------------------------------- +// The TCF mapping, now the TCF crate's, still reaches every purpose. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // Consent to all purposes except Purpose 7 (measure ad performance), in a + // US opt-out state where the baseline granted them all, so a revoke is + // observable as a drop. + let consented: Vec = (1..=11).filter(|&purpose| purpose != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + assert!( + state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" + ); +} + +// ---------------------------------------------------------------------- +// Withdrawal scoping: only a TCF storage refusal withdraws, and only where +// the baseline did not grant storage outright. Opt-outs suppress use but +// never destroy an already-issued identifier. +// +// No location resolves at the policy's top node, the gdpr-eu group, where +// storage requires a signal. A US opt-out state grants it outright. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + state.storage_withdrawn(), + "refusing the signal storage depends on should withdraw" + ); +} + +#[test] +fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.storage_withdrawn(), + "storage never depended on the record here, so refusal suppresses without destroying" + ); +} + +#[test] +fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "a consenting record is not a withdrawal" + ); +} + +#[test] +fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_four()).storage_withdrawn() + && !assembled(&consent, GeoStatus::NoLocation, &all_four()).storage_withdrawn(), + "GPC suppresses use for the request but never destroys the identifier" + ); +} + +#[test] +fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "sale opt-outs suppress use but never destroy the identifier" + ); +} + +#[test] +fn no_signal_is_not_a_withdrawal() { + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &all_four(), + ); + assert!( + !state.storage_withdrawn(), + "absence of a signal must never destroy an identifier" + ); +} + +#[test] +fn a_withdrawal_needs_the_tcf_provider_to_be_running() { + // The withdrawal is TCF's answer, so a deployment that removed the TCF + // provider from the list has no scheme left that can withdraw. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_but("tcf")); + assert!( + !state.storage_withdrawn(), + "a scheme that does not run cannot withdraw, whatever the request carries" + ); +} + +// ---------------------------------------------------------------------- +// Unreadable and expired records, assembled with the real providers. +// ---------------------------------------------------------------------- + +#[test] +fn a_malformed_tcf_record_blocks_baseline_grants() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + assert!( + !state.storage_withdrawn(), + "and it fails closed by suppression, never destructively" + ); +} + +#[test] +fn a_readable_tcf_record_does_not_cure_an_unreadable_gpp_string() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "one scheme arriving unreadable is not cured by another scheme arriving readable" + ); +} + +#[test] +fn an_expired_tcf_record_is_not_treated_as_malformed() { + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 6812b7421..ea9d7067e 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] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings") @@ -867,3 +873,59 @@ async fn first_party_proxy_rebuild_is_routed() { "/first-party/proxy-rebuild must be routed" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction operation + # is acknowledged because no geo provider is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and the Axum dev +/// server injects no vendor Edge Cookie provider, so only the composition root +/// can catch it. Without the startup check the deployment would come up and +/// answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..0bea7d5f7 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -30,6 +30,10 @@ futures = { workspace = true } log = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } worker = { workspace = true, optional = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7ea582ee8..47fea3e1c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -20,6 +20,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -72,6 +73,22 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, + /// The permission signal providers `[permission_signal] provider` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -137,52 +154,98 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; 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_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. Nothing is carried when the composition root +/// found nothing safe to keep, and the request path resolves for itself. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) + .with_resolved_ec_provider(state.ec_provider.clone()) } /// 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 provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(settings, req, services) } // --------------------------------------------------------------------------- @@ -209,7 +272,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -407,7 +470,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -449,7 +512,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -554,7 +623,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, @@ -618,7 +687,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(), @@ -659,8 +728,73 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction + # operation is acknowledged because no geo provider is selected. + [geo] + assume_single_jurisdiction = true + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx, &settings, &Arc::default()); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" @@ -677,6 +811,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -725,6 +862,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -769,6 +909,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..ba31f6ba3 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] + provider = "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 cded42a0d..0a7634166 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -590,7 +590,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_providers: &Arc< + [Arc], + >, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -625,7 +631,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] provider selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_provider(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -633,7 +641,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 providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -650,15 +662,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 { @@ -673,7 +693,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -707,12 +727,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, } } @@ -789,6 +816,79 @@ 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() { + // This adapter used to hardcode `region: None` and read no region + // header, and the consequence ran all the way to 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, so every US + // visitor fell through to the country node's `NonRegulated`, where + // `allows_ec_creation` returns true without ever reading `ctx.gpc`. A + // Sec-GPC opt-out was therefore ignored for every US visitor on + // Cloudflare. + 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 fb498ce4e..e597211a6 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] + provider = "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] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -675,3 +687,59 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { "tsjs catch-all handler must not return 5xx: got {status}" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction operation + # is acknowledged because no geo provider is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 65320faa6..5110cf602 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -28,6 +28,12 @@ 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-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 190be505c..2f07f4978 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -113,10 +113,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::provider::request_provider; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -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_provider, }; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -181,6 +185,22 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + pub(crate) ec_provider: Option>, + /// The permission signal providers `[permission_signal] provider` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + pub(crate) permission_signal_providers: + Arc<[Arc]>, } /// Build the application state, loading settings and constructing all per-application components. @@ -207,15 +227,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 provider 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 provider 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 provider, 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_provider` hands back nothing for a provider built from + // those signals, leaving it to be resolved per request against the + // signals that request actually carried. + let ec_provider = build_reusable_provider( + &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_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -226,9 +277,30 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + fn warn_if_certificate_check_disabled(settings: &Settings) { if !settings.proxy.certificate_check { log::warn!( @@ -293,7 +365,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 provider 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)) @@ -304,10 +393,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_provider( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) - .build() + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(&state.permission_signal_providers)) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); + + // Hand every request the provider resolved at the composition root, so the + // request path reuses that instance instead of resolving `[ec] provider` + // again. Nothing is set for a deployment that selects no provider, or one + // whose provider is built from this request's own host signals, and both + // are resolved on the request path instead. + match state.ec_provider.clone() { + Some(provider) => builder.resolved_ec_provider(provider).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -429,7 +535,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={})", @@ -442,16 +548,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. @@ -465,18 +563,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 { @@ -515,11 +616,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 @@ -528,6 +633,7 @@ async fn run_pre_route_filters( services, req, geo_info, + permissions, }) .await { @@ -591,7 +697,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected provider decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let provider = request_provider(&state.settings.ec, &services)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, provider.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -619,13 +730,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 @@ -748,14 +866,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 provider + // created, so validation and KV normalization are dispatched through + // that provider rather than the built-in HMAC grammar. + let provider = request_provider(&state.settings.ec, services)?; + handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); @@ -808,7 +930,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); @@ -861,7 +990,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Publisher pages read consent data, so the consent KV store must be @@ -1300,7 +1429,7 @@ impl TrustedServerApp { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), - Arc::new(FastlyPlatformGeo), + build_geo_provider(&state.settings, Arc::new(FastlyPlatformGeo)), )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); @@ -1505,8 +1634,14 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1576,8 +1711,14 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "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" @@ -1651,6 +1792,11 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // Resolved the same way the composition root resolves it, so this + // router behaves like a served one. + let ec_provider = + trusted_server_core::ec::provider::build_reusable_provider(&settings.ec, None, None) + .expect("should resolve the Edge Cookie provider selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1659,6 +1805,10 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, + // These tests exercise routing, and a request with no signal + // provider resolves at the place baseline. + permission_signal_providers: Arc::default(), }); TrustedServerApp::routes_for_state(&state) } @@ -2043,7 +2193,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse production-shaped settings"); @@ -2696,8 +2852,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "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" @@ -2961,6 +3123,12 @@ mod tests { [ec] passphrase = "test-secret-key-32-bytes-minimum" + # The deprecated passphrase migrates to the hmac provider, so + # single-jurisdiction operation is acknowledged because no + # geo provider is selected. + [geo] + assume_single_jurisdiction = true + [auction] enabled = true providers = {} @@ -3117,8 +3285,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "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 367a3e33f..a097048ee 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; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals, build_device_provider}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -22,12 +24,14 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::build_geo_provider; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -168,7 +172,42 @@ fn edgezero_main(mut req: FastlyRequest) { // 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 @@ -286,8 +325,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_provider(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -493,16 +536,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 providers 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 +/// provider 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 provider 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_provider(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceProvider::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] @@ -528,7 +588,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -655,6 +721,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..5ec2530bb 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] + provider = "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 a29b48fda..322596102 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -9,18 +9,16 @@ use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, - PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, + PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -630,33 +628,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo provider now lives in its own crate, +/// `trusted-server-geo-fastly`, so every provider implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's existing call sites keep +/// referring to it through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; 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-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 77c4139bc..a0a5887a2 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,6 +32,10 @@ futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 290de5aec..058f45277 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::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -67,14 +68,38 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, + /// The permission signal providers `[permission_signal] provider` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_providers: + Arc<[Arc]>, } /// 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. This +/// adapter previously compiled `trusted-server.example.toml` into the binary +/// and parsed it here, which could never succeed, because that template ships +/// placeholder secrets and the placeholder admin password fails validation. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialize. fn build_state() -> Result, Report> { let settings = load_startup_settings()?; build_state_with_settings(settings) @@ -118,23 +143,58 @@ 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 provider 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> { + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; 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_providers = + trusted_server_core::permission_signal::build_permission_signal_providers( + &settings, + &shipped_signal_providers(), + )?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, + permission_signal_providers, })) } +/// The permission signal providers this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no provider of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_providers() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -391,25 +451,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 provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { + EcContext::read_from_request_resolving_geo(settings, req, services) } fn admin_key_management_not_supported() -> Response { @@ -550,6 +616,14 @@ impl TrustedServerApp { } } +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx, &state.settings, &state.permission_signal_providers) + .with_resolved_ec_provider(state.ec_provider.clone()) +} + fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); @@ -559,7 +633,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -571,7 +645,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -604,7 +678,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -621,8 +695,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, @@ -642,7 +721,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -652,7 +731,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 provider 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(), @@ -677,7 +762,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -690,7 +775,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -703,7 +788,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -720,7 +805,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -736,7 +821,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -775,7 +860,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -908,6 +999,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 { @@ -926,6 +1020,9 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse startup test settings"); @@ -1004,6 +1101,98 @@ mod tests { ); } + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // This adapter used to compile the shipped example template into the + // binary and parse it here. That template carries placeholder secrets + // by design, and the placeholder admin password fails + // `validate_admin_handler_passwords`, so `build_state` could never + // return `Ok` and the router fell back to the start-up error handler + // that answers every request with 503. Nothing caught it because every + // other test enters through the `routes_with_settings` parity seam and + // never calls this function. + // + // There is no Spin runtime under `cargo test`, so there are no + // component variables to read and this cannot return `Ok` here. What it + // must never do again is fail because of a configuration baked into the + // binary, so the failure has to be the absence of a config store and + // nothing else. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction + # operation is acknowledged because no geo provider is selected. + [geo] + assume_single_jurisdiction = true + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx, &settings, &Arc::default()); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..1dc439fb9 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] + provider = "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 a81911186..56beb7cfc 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -708,7 +708,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_providers: &Arc< + [Arc], + >, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -737,7 +743,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] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::new(NullGeo), + )) + // The signal providers were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_providers(Arc::clone(permission_signal_providers)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1044,7 +1060,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 f75ea687e..ac7e730b8 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] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -973,3 +979,59 @@ async fn admin_deactivate_key_auth_fail_returns_401() { "admin/keys/deactivate without credentials must return 401" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + + # An Edge Cookie provider is configured, so single-jurisdiction operation + # is acknowledged because no geo provider is selected. + [geo] + assume_single_jurisdiction = true +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 01780dd39..98592f7cb 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/README.md b/crates/trusted-server-core/README.md index 69575b0d2..1fe656eb6 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -51,7 +51,7 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr - The `ec/` module owns the EC identity subsystem: - `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`). - - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper. + - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`). - `ec/consent.rs` — EC-specific consent gating wrapper. - `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers. - `publisher.rs::handle_publisher_request` issues the `ts-ec` cookie when absent so the browser keeps the identifier on subsequent requests. diff --git a/crates/trusted-server-core/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 c0c0a7792..5392f7c71 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 provider's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -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 + // provider's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -624,6 +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, CanonicalizingProvider}; 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") @@ -796,6 +821,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 provider's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a provider whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = make_non_regulated_ec_context(Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(Arc::new(CanonicalizingProvider)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; @@ -914,7 +1017,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", @@ -963,7 +1070,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", @@ -1026,7 +1133,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", @@ -1088,7 +1195,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": [ @@ -1285,7 +1394,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!( @@ -1297,7 +1406,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!( @@ -1310,7 +1419,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!( @@ -1322,7 +1431,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!( @@ -1335,7 +1448,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 6be0c9cfa..69b4ec4ce 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,8 +11,9 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::provider::{HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -32,7 +33,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +use crate::settings::{ + AssetOriginAuth, Ec, IntegrationConfig, PROVIDER_IMPLEMENTATION_KEY, Settings, +}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -117,6 +120,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_provider_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +135,100 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie provider blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_provider_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + let labeled = ec + .provider_blocks + .hmac_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HMAC_PROVIDER_KEY) + .chain( + ec.provider_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_PROVIDER_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 provider block that configures a + /// provider 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 providers' blocks has when its name + /// is its implementation. The same provider under a label of the + /// operator's choosing holds that secret at `ec.