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/CLAUDE.md b/CLAUDE.md index 546a3bf52..3aa298744 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,10 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -58,7 +62,9 @@ fastly compute serve # Deploy to Fastly fastly compute publish -# Run Axum dev server (native — no Viceroy) +# Run Axum dev server (native — no Viceroy). Settings load at runtime from the +# platform config store on every adapter; publish an operator config with +# `ts config push` (see trusted-server.example.toml for the template). cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -142,6 +148,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.17.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +The Rust adapter tests run natively on Windows through the cargo aliases +(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), +and CI runs these on both `ubuntu-latest` and `windows-latest`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the +WSL distro with the same toolchain as `.tool-versions` (rustup + the +`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then +run the scripts from a clone on the WSL native filesystem for fast builds. + --- ## Coding Conventions @@ -269,12 +289,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 @@ -291,6 +347,41 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- | +| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 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: @@ -324,7 +415,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) | | `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 8e1ceb1f3..58f0c055f 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" @@ -5400,6 +5413,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5499,6 +5514,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5513,6 +5529,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" @@ -5653,6 +5686,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 7faba7553..3301e7777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -95,6 +97,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" sha2 = "0.10.9" simple_logger = "5" spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } @@ -109,6 +112,8 @@ toml = "1.1" toml_edit = "0.23.10" tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } +trusted-server-device-fastly = { path = "crates/device/fastly" } +trusted-server-geo-fastly = { path = "crates/geo/fastly" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/config/permissions/vanilla.yaml b/config/permissions/vanilla.yaml new file mode 100644 index 000000000..2c8fc2fcc --- /dev/null +++ b/config/permissions/vanilla.yaml @@ -0,0 +1,562 @@ +# 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. +# +# No policy ever ships with Trusted Server. The builder of a deployment +# chooses the permissions.yaml compiled into their image, an operator overlay +# is the recorded follow-on, and the visitor's signals decide the rest at +# runtime. This copy is the repository's plain vanilla sample, compiled into +# test and demo builds so the rules stay visible and reviewable in version +# control. The samples live in config/permissions, and choosing one is always +# an explicit choice by whoever builds, configures, or inspects. +# +# 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: Vanilla + +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 + +# How each session signal maps onto Data Uses. The permission engine reads this, +# so no signal-to-permission policy lives in the code. For each Data Use a signal +# produces a grant or a revoke, which the resolver then applies against the group +# baseline above (a grant sets a `requires_signal` Data Use, a revoke drops a +# `granted` one, and `denied` always wins so no signal can set it). +signals: + # A present TCF v2 record (a standalone TC string, or the EU TCF section of a + # GPP string). With authoritative true, each listed TCF purpose grants the + # Data Use it maps to when the record consents to that purpose, and revokes it + # otherwise; with authoritative false the record is ignored. The flag governs + # only the TCF record's own grants and revokes: an opt-out signal below always + # suppresses the Data Uses it revokes, even alongside a consenting TCF record, + # because an explicit opt-out is never overridden by another signal. This is + # the interim home for the purpose to Data Use mapping, and a purpose may + # grant one Data Use or a list of them. Once the IAB Privacy Taxonomy tcf + # column is finalized (fideslang) that becomes the single source and this + # block is dropped. + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 2: + - advertising_marketing.first_party.contextual + - advertising_marketing.frequency_capping + - advertising_marketing.negative_targeting + 3: advertising_marketing.profiling + 4: + - advertising_marketing.first_party.targeted + - advertising_marketing.third_party.targeted + 5: advertising_marketing.personalize.profiling + 6: + - advertising_marketing.personalize.content + - advertising_marketing.personalize.system + - functional.personalization + 7: + - analytics.ad_reporting.measure_ad_performance + - analytics.ad_reporting.ad_delivery_and_targeting + - analytics.ad_reporting.ad_viewability + 8: analytics.ad_reporting.content_performance + 9: + - analytics.ad_reporting.market_research + - analytics.ad_reporting.campaign_insights + 10: necessary.operations.improve + 11: select-basic-content + # US-style opt-out of sale or sharing. It applies when any listed source is + # set, and it suppresses the Data Uses it revokes even when a TCF record + # consents to them. The list below models the US state opt-out scope, being + # sale, sharing, targeted advertising, and device storage as the + # conservative reading, so an opted-out visitor gets no Edge Cookie + # written and no identifier shared, whilst contextual advertising and + # measurement continue after an opt-out. A deployer can widen the list, or write `revokes: all` to drop + # every granted Data Use. An opt-out suppresses use for the request; it is + # never a destructive withdrawal of an already-issued identifier. + 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..6d15fd719 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,10 @@ +# 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; a deployment selects one explicitly with `[ec] provider`. +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/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9a371f805..90bcac37e 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -16,6 +16,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}; @@ -69,11 +70,26 @@ 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 orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -133,7 +149,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -153,25 +169,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) } // --------------------------------------------------------------------------- @@ -218,7 +240,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(), @@ -454,7 +476,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 ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -473,7 +495,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -640,3 +662,81 @@ 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.providers.]` block configuration validation + /// requires. `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.providers.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 orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + } + } + + /// 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); + 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..9e81d2546 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.providers.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 a511daab2..4886bd6e4 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -533,7 +533,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route /// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!( @@ -578,9 +581,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index ed199e6bf..4613cbda7 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_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -829,3 +835,58 @@ 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.providers.]` block configuration validation +/// requires. `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.providers.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 pair (the `[ec.providers.acme]` block +/// is present), 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/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 6ce0a5ee3..fad5e65a4 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,6 +18,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}; @@ -56,6 +57,16 @@ 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>, } /// Build the application state, loading settings and constructing all per-application components. @@ -108,11 +119,20 @@ 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 orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -120,6 +140,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -127,31 +148,42 @@ fn build_state_with_settings( // 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) + .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) } // --------------------------------------------------------------------------- @@ -178,7 +210,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, @@ -376,7 +408,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); @@ -418,7 +450,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(), @@ -523,7 +561,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 ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -587,7 +625,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 ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -625,3 +663,73 @@ 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.providers.]` block configuration validation + /// requires. `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.providers.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); + 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}" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..ca96e9989 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.providers.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 fff0bfed1..7bdeefe30 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -598,7 +598,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -633,7 +636,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] provider selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_provider(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -641,7 +646,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -658,15 +663,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 { @@ -681,7 +694,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -715,12 +728,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, } } @@ -778,6 +798,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..9411d59f0 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.providers.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.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -675,3 +687,58 @@ 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.providers.]` block configuration validation +/// requires. `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.providers.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 pair (the `[ec.providers.acme]` block +/// is present), 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 47cc609b2..d0a43e670 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -29,6 +29,8 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-device-fastly = { workspace = true } +trusted-server-geo-fastly = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 4ba61f603..2445ca467 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -109,10 +109,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; @@ -120,7 +121,12 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::permissions::PermissionState; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, build_geo_provider, +}; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -160,6 +166,16 @@ 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>, } /// Build the application state, loading settings and constructing all per-application components. @@ -180,11 +196,37 @@ pub(crate) fn load_settings_from_config_store( get_settings_from_config_store(&FastlyPlatformConfigStore, &store_name, &key) } +/// 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 orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -197,6 +239,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, })) } @@ -264,7 +307,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)) @@ -275,10 +335,23 @@ 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() + .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] { @@ -400,7 +473,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={})", @@ -413,34 +486,29 @@ fn build_ec_request_state( let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during EC setup: {e}"); - None - }); - let (ec_context, setup_error) = - match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + match EcContext::read_from_request_resolving_geo(settings, req, services) { Ok(mut context) => { context.set_device_signals(device_signals); (context, None) } Err(report) => (EcContext::default(), Some(report)), }; + let geo_info = ec_context.geo_info().cloned(); // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations keep the write path so tombstones stay - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // stays open for withdrawal so tombstones remain authoritative even for + // privacy-extension-heavy clients that do not look like known browsers. A + // merely not-permitted (pre-consent or fail-closed) request writes nothing, + // so it does not need the graph. let kv_graph = crate::maybe_identity_graph(settings); - let finalize_kv_graph = if setup_error.is_none() - && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) - { - kv_graph.clone() - } else { - None - }; + let finalize_kv_graph = + if setup_error.is_none() && (is_real_browser || ec_context.storage_withdrawn()) { + kv_graph.clone() + } else { + None + }; let kv_graph = if is_real_browser { kv_graph } else { None }; EcRequestState { @@ -479,11 +547,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 @@ -492,6 +564,7 @@ async fn run_pre_route_filters( services, req, geo_info, + permissions, }) .await { @@ -555,7 +628,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"), @@ -583,13 +661,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 @@ -712,14 +797,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)); @@ -772,7 +861,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); @@ -825,7 +921,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 @@ -1254,7 +1350,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))); @@ -1372,8 +1468,14 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1441,8 +1543,14 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1524,6 +1632,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, @@ -1532,6 +1645,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, }); TrustedServerApp::routes_for_state(&state) } @@ -1888,7 +2002,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse production-shaped settings"); @@ -2541,8 +2661,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -2802,6 +2928,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 = [] @@ -2956,8 +3088,14 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.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 8a73a80db..640b69b99 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -8,14 +8,16 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; 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::{ @@ -23,13 +25,15 @@ 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_core::settings_data::config_store_name; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -166,7 +170,42 @@ fn edgezero_main(mut req: FastlyRequest, env: &EnvConfig) { // 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 @@ -284,8 +323,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 }) @@ -491,16 +534,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)] @@ -526,7 +586,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.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -653,6 +719,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..9d6f22322 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.providers.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 638aed82b..ff00c543f 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -9,14 +9,13 @@ use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, @@ -667,33 +666,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/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index f24b5b717..5ce3fb300 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,6 +16,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; @@ -34,11 +35,14 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::{ + default_config_key, default_config_store_name, get_settings_from_config_store, +}; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; -use crate::platform::build_runtime_services; +use crate::platform::{SpinPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -49,16 +53,37 @@ 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>, } /// 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 = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; + let store_name = default_config_store_name(); + let config_key = default_config_key(); + let settings = + get_settings_from_config_store(&SpinPlatformConfigStore, &store_name, &config_key)?; build_state_with_settings(settings) } @@ -66,11 +91,20 @@ 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: 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 orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -78,6 +112,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -337,25 +372,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 { @@ -496,6 +537,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) + .with_resolved_ec_provider(state.ec_provider.clone()) +} + fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); @@ -505,7 +554,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))) @@ -517,7 +566,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))) @@ -550,7 +599,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 @@ -567,8 +616,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 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 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, @@ -588,7 +642,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( @@ -598,7 +652,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let 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 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(), @@ -623,7 +683,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 @@ -636,7 +696,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 @@ -649,7 +709,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 @@ -666,7 +726,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) @@ -682,7 +742,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); @@ -721,7 +781,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(), @@ -854,8 +920,103 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + #[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.providers.]` block configuration validation + /// requires. `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.providers.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); + 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..100d4fac1 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.providers.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 492f1a518..642c82b87 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -149,6 +149,51 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } +/// Reads Trusted Server app config from Spin component variables, with no +/// request in hand. +/// +/// Application state is built before any request context exists, so the +/// per-request [`ConfigStoreHandleAdapter`] cannot serve it. Spin component +/// variables are ambient rather than request-scoped, which is how +/// `SpinSecretStoreAdapter` already reads secrets, so the same variables are +/// read directly here. Both paths map keys through [`spin_variable_name`], so +/// start-up and the request path read the same variable for the same key. +/// +/// Outside the Spin runtime, which includes every `cargo test` run on the host, +/// there are no component variables and every read reports that rather than +/// falling back to a configuration compiled into the binary. +pub struct SpinPlatformConfigStore; + +impl PlatformConfigStore for SpinPlatformConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + { + let variable_name = spin_variable_name(key, PlatformError::ConfigStore)?; + futures::executor::block_on(spin_sdk::variables::get(&variable_name)).map_err(|error| { + Report::new(PlatformError::ConfigStore).attach(format!( + "config store lookup failed for key `{key}` as Spin variable `{variable_name}`: {error}" + )) + }) + } + #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] + { + Err(Report::new(PlatformError::ConfigStore).attach(format!( + "no config store is available for key `{key}` outside the Spin runtime, where component variables cannot be read" + ))) + } + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } +} + fn spin_variable_name( key: &str, error_context: PlatformError, @@ -715,7 +760,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -744,7 +792,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::new(NullGeo), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -994,7 +1048,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = build_runtime_services(&ctx); + let services = + build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..51473dc71 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.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings"); @@ -973,3 +979,58 @@ 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.providers.]` block configuration validation +/// requires. `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.providers.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 pair (the `[ec.providers.acme]` block +/// is present), 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 e44d46f77..afff4c696 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -40,6 +40,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } toml = { workspace = true } diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 3049a1115..53fd75774 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 fdf387e93..86bd7b981 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -171,8 +171,9 @@ pub async fn handle_auction( // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. - // Only forward the EC ID to auction partners when consent allows it. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted: + // storage plus personalised-ad selection, the same pair that gates EIDs. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value() } else { None @@ -237,8 +238,9 @@ pub async fn handle_auction( // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. // - // Gate this on the same identity-consent condition as the EC ID - // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Gate this on the same identity condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by the sharing pair via + // `ec_context.ec_sharing_allowed()`). // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` @@ -282,10 +284,9 @@ pub async fn handle_auction( // consent gating before attaching them to the auction request. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); - auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + auction_request.user.eids = gate_eids_by_permissions(merged_eids, ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { - log::warn!("Auction EIDs stripped by TCF consent gating"); + log::warn!("Auction EIDs stripped: bidstream permissions not set"); } // Create auction context @@ -616,13 +617,11 @@ mod tests { .build() } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), - ConsentContext { - jurisdiction, - ..ConsentContext::default() - }, + ConsentContext::default(), + ec_allowed, ) } @@ -744,7 +743,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -798,7 +801,9 @@ mod tests { let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + // The default consent context keeps the jurisdiction unknown, so the + // server-side auction gate fails closed; the EC gate is off to match. + let ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -995,7 +1000,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_kv() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(None, Some(®istry), &ec_context); assert!(result.is_none(), "should return None when KV is missing"); @@ -1005,7 +1010,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_registry() { let kv = KvIdentityGraph::failing("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), None, &ec_context); assert!( @@ -1019,7 +1024,7 @@ mod tests { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1032,7 +1037,11 @@ mod tests { fn resolve_auction_eids_returns_none_when_no_ec() { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -1046,7 +1055,7 @@ mod tests { let kv = KvIdentityGraph::failing("nonexistent_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); // KV store doesn't exist, so the get() call will error — should return // empty Vec (degraded mode), not None. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index ad4f66460..2c9624b02 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -469,7 +469,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 907373073..0c3258f1a 100644 --- a/crates/trusted-server-core/src/consent/jurisdiction.rs +++ b/crates/trusted-server-core/src/consent/jurisdiction.rs @@ -1,32 +1,86 @@ -//! Jurisdiction detection for consent observability. +//! Jurisdiction detection: which privacy regime a request falls under. //! -//! Determines the applicable privacy regime based on geolocation data and -//! publisher configuration. Used for **logging and monitoring only** — the -//! detected jurisdiction never causes consent to be synthesized (see proposal -//! Key Decision #3). +//! The regime comes from the request's location, resolved through the same +//! place tree in `permissions.yaml` that decides the permission baseline, so +//! one file states the policy for both. The detected jurisdiction never causes +//! consent to be synthesized (see proposal Key Decision #3), but it is not +//! only observability either: the server-side auction gate +//! (`consent_allows_server_side_auction`) fails closed on a GDPR or unknown +//! jurisdiction, and a US state jurisdiction is what lets a GPC header be +//! turned into a US Privacy string. use core::fmt; -use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; +use crate::permissions::PermissionMaps; /// The privacy jurisdiction applicable to a request. /// -/// Derived from the user's geolocation and the publisher's configured -/// country/state lists. Used for observability — not for consent synthesis. +/// Resolved from the request's place through the `rules` tree in +/// `permissions.yaml`, where each node may name the jurisdiction that applies +/// there and a node without one inherits from the node above it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Jurisdiction { - /// GDPR applies (EU/EEA/UK per `consent.gdpr.applies_in`). + /// GDPR applies (the EU, EEA and UK regime). Gdpr, /// A US state with an active comprehensive privacy law. UsState(String), - /// Geolocation is known but no matching regulation was found. + /// The place is known but no matching regulation was found. NonRegulated, - /// No geolocation data available — jurisdiction cannot be determined. + /// No jurisdiction could be determined, for example after a failed geo + /// lookup. #[default] Unknown, } +impl Jurisdiction { + /// Parses a `jurisdiction:` value written in the `permissions.yaml` + /// `rules` tree. + /// + /// The vocabulary covers exactly the states this type can represent, so a + /// policy owner cannot write a jurisdiction the consent code has no way of + /// applying: + /// + /// - `gdpr` for [`Jurisdiction::Gdpr`], the EU, EEA and UK regime. + /// - `us-state` for [`Jurisdiction::UsState`]. It carries no code, because + /// the node that names it is itself a region, so `region` supplies the + /// state (upper-cased). A node with no region of its own, meaning the top + /// of the tree or a country, cannot name it, and `None` is returned. + /// - `non-regulated` for [`Jurisdiction::NonRegulated`], a place with no + /// matching regulation. + /// - `unknown` for [`Jurisdiction::Unknown`], declining to name one. + /// + /// `region` is the ISO 3166-2 subdivision code of the node carrying the + /// value, or `None` for the top of the tree and for a country. + /// + /// Returns `None` for anything else, which the permission policy parser + /// reports as a configuration error rather than silently defaulting. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::consent::jurisdiction::Jurisdiction; + /// + /// assert_eq!(Jurisdiction::from_policy_name("gdpr", None), Some(Jurisdiction::Gdpr)); + /// assert_eq!( + /// Jurisdiction::from_policy_name("us-state", Some("ca")), + /// Some(Jurisdiction::UsState("CA".to_owned())) + /// ); + /// assert_eq!(Jurisdiction::from_policy_name("us-state", None), None); + /// assert_eq!(Jurisdiction::from_policy_name("nonsense", None), None); + /// ``` + #[must_use] + pub fn from_policy_name(value: &str, region: Option<&str>) -> Option { + match value { + "gdpr" => Some(Self::Gdpr), + "us-state" => region.map(|code| Self::UsState(code.to_uppercase())), + "non-regulated" => Some(Self::NonRegulated), + "unknown" => Some(Self::Unknown), + _ => None, + } + } +} + impl fmt::Display for Jurisdiction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -38,44 +92,29 @@ impl fmt::Display for Jurisdiction { } } -/// Detects the privacy jurisdiction for a request based on geolocation. +/// Detects the privacy jurisdiction for a request from its location. +/// +/// Walks the `permissions.yaml` place tree the same way the permission +/// baseline is resolved: the request's region when it is listed, otherwise its +/// country, otherwise the top of the tree. A node that names no jurisdiction of +/// its own inherits the one above it, which is resolved when the file is +/// parsed. /// -/// Checks the user's country against `config.gdpr.applies_in`, and for US -/// users checks the region against `config.us_states.privacy_states`. +/// The tree also settles the `DE` collision on its own, because ISO 3166-1 +/// `DE` is Germany and sits at the country level, while ISO 3166-2 `DE` is +/// Delaware and sits under `US`. /// -/// Returns [`Jurisdiction::Unknown`] when no geo data is available. +/// With no location this returns the top node's jurisdiction, the policy's +/// declared answer for a visitor whose place is not resolved. A caller that +/// must not apply that declaration, such as one holding a failed geo lookup, +/// resolves [`Jurisdiction::Unknown`] itself rather than calling this. #[must_use] -pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jurisdiction { - let Some(geo) = geo else { - return Jurisdiction::Unknown; - }; - - // Check GDPR countries first (EU/EEA/UK). This ordering also resolves - // the `DE` code collision: ISO 3166-1 `DE` is Germany (GDPR), while - // US-Delaware uses ISO 3166-2 `US-DE`. The US state check below only - // triggers when `country == "US"`, so there is no actual ambiguity. - if config - .gdpr - .applies_in - .iter() - .any(|code| code.eq_ignore_ascii_case(&geo.country)) - { - return Jurisdiction::Gdpr; +pub fn detect_jurisdiction(geo: Option<&GeoInfo>) -> Jurisdiction { + let maps = PermissionMaps::standard(); + match geo { + Some(geo) => maps.jurisdiction_for(Some(&geo.country), geo.region.as_deref()), + None => maps.default_jurisdiction(), } - - // For US users, check if the region is a state with a privacy law. - if geo.country.eq_ignore_ascii_case("US") - && let Some(region) = &geo.region - && config - .us_states - .privacy_states - .iter() - .any(|state| state.eq_ignore_ascii_case(region)) - { - return Jurisdiction::UsState(region.to_uppercase()); - } - - Jurisdiction::NonRegulated } // --------------------------------------------------------------------------- @@ -85,14 +124,13 @@ pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jur #[cfg(test)] mod tests { use super::{Jurisdiction, detect_jurisdiction}; - use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; fn make_geo(country: &str, region: Option<&str>) -> GeoInfo { GeoInfo { - city: "Test".to_owned(), + city: "Test City".to_owned(), country: country.to_owned(), - continent: "Test".to_owned(), + continent: "EU".to_owned(), latitude: 0.0, longitude: 0.0, metro_code: 0, @@ -103,10 +141,9 @@ mod tests { #[test] fn gdpr_detected_for_eu_country() { - let config = ConsentConfig::default(); let geo = make_geo("DE", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, "Germany should trigger GDPR" ); @@ -114,10 +151,9 @@ mod tests { #[test] fn gdpr_detected_for_eea_country() { - let config = ConsentConfig::default(); let geo = make_geo("NO", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, "Norway (EEA) should trigger GDPR" ); @@ -125,77 +161,98 @@ mod tests { #[test] fn gdpr_detected_for_uk() { - let config = ConsentConfig::default(); let geo = make_geo("GB", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, - "UK should trigger GDPR" + "the UK should trigger GDPR" ); } #[test] fn us_state_detected_for_california() { - let config = ConsentConfig::default(); let geo = make_geo("US", Some("CA")); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::UsState("CA".to_owned()), "California should trigger US state privacy" ); } #[test] - fn us_non_privacy_state_is_non_regulated() { - let config = ConsentConfig::default(); + fn delaware_is_a_us_state_and_germany_is_not() { + // ISO 3166-1 `DE` is Germany and ISO 3166-2 `DE` is Delaware. The tree + // keeps them apart by where they sit, so no ordering rule is needed. + let delaware = make_geo("US", Some("DE")); + assert_eq!( + detect_jurisdiction(Some(&delaware)), + Jurisdiction::UsState("DE".to_owned()), + "US/DE should be Delaware" + ); + let germany = make_geo("DE", None); + assert_eq!( + detect_jurisdiction(Some(&germany)), + Jurisdiction::Gdpr, + "DE at the country level should be Germany" + ); + } + + #[test] + fn us_non_privacy_state_inherits_the_country_node() { let geo = make_geo("US", Some("WY")); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::NonRegulated, - "Wyoming should be non-regulated" + "Wyoming is not listed, so it inherits the US node" ); } #[test] fn us_no_region_is_non_regulated() { - let config = ConsentConfig::default(); let geo = make_geo("US", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::NonRegulated, - "US without region should be non-regulated" + "the US without a region should be non-regulated" ); } #[test] - fn non_gdpr_non_us_is_non_regulated() { - let config = ConsentConfig::default(); + fn an_unlisted_country_inherits_the_top_of_the_tree() { + // Nothing is written for Japan, so it inherits the top node, which the + // shipped policy sets to GDPR. That is the same node an unresolved + // place gets, so an unlisted country is treated no more loosely than a + // visitor with no place at all. let geo = make_geo("JP", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), - Jurisdiction::NonRegulated, - "Japan should be non-regulated" + detect_jurisdiction(Some(&geo)), + Jurisdiction::Gdpr, + "an unlisted country should inherit the top of the tree" ); } #[test] - fn no_geo_returns_unknown() { - let config = ConsentConfig::default(); + fn no_geo_uses_the_top_of_the_tree() { assert_eq!( - detect_jurisdiction(None, &config), - Jurisdiction::Unknown, - "missing geo should return unknown" + detect_jurisdiction(None), + Jurisdiction::Gdpr, + "with no place the policy's declared top node applies" ); } #[test] - fn case_insensitive_country_matching() { - let config = ConsentConfig::default(); + fn case_insensitive_place_matching() { let geo = make_geo("de", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, - "lowercase country code should still match" + "a lowercase country code should still match" + ); + let state = make_geo("us", Some("ca")); + assert_eq!( + detect_jurisdiction(Some(&state)), + Jurisdiction::UsState("CA".to_owned()), + "a lowercase region code should still match and upper-case the state" ); } diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..df98bd7aa 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -54,6 +54,7 @@ use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::permissions::{Permission, PermissionState}; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -76,6 +77,16 @@ pub struct ConsentPipelineInput<'a> { pub config: &'a ConsentConfig, /// Geolocation data from the request (for jurisdiction detection). pub geo: Option<&'a GeoInfo>, + /// The jurisdiction to apply when `geo` resolved no location. + /// + /// Jurisdiction is detected from geolocation, so with no location every + /// request would be [`Jurisdiction::Unknown`] and the consent gates would + /// fail closed even where the permission policy declares what to do. This + /// carries that declaration (see + /// [`PermissionMaps::default_jurisdiction`](crate::permissions::PermissionMaps::default_jurisdiction)). + /// Pass [`Jurisdiction::Unknown`] where no declaration applies, for example + /// after a failed geo lookup. + pub default_jurisdiction: jurisdiction::Jurisdiction, /// EC ID for KV Store consent persistence. /// /// When set along with `kv_store`, enables: @@ -130,14 +141,14 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext { // Jurisdiction is request-local: derive it from the current // geo rather than the value stored with the persisted entry. - ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); + ctx.jurisdiction = request_jurisdiction(input); log_consent_context(&ctx); return ctx; } // In proxy mode, skip decoding entirely. if input.config.mode == ConsentMode::Proxy { - let jur = jurisdiction::detect_jurisdiction(input.geo, input.config); + let jur = request_jurisdiction(input); let gpp_section_ids = signals .raw_gpp_sid .as_deref() @@ -163,7 +174,7 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext } let mut ctx = build_context_from_signals(&signals); - ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); + ctx.jurisdiction = request_jurisdiction(input); apply_tcf_conflict_resolution(&mut ctx, input.config); apply_expiration_check(&mut ctx, input.config); apply_gpc_us_privacy(&mut ctx, input.config); @@ -179,6 +190,19 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext ctx } +/// The jurisdiction for a request: detected from its location when one +/// resolved, otherwise the declaration the caller supplied. +/// +/// Keeping the two in one place means every path through the pipeline (proxy +/// mode, the KV read fallback, and the ordinary decode) answers the question +/// the same way. +fn request_jurisdiction(input: &ConsentPipelineInput<'_>) -> jurisdiction::Jurisdiction { + match input.geo { + Some(_) => jurisdiction::detect_jurisdiction(input.geo), + None => input.default_jurisdiction.clone(), + } +} + /// Marks TCF consent as expired when it exceeds the configured maximum age. /// /// Clears whichever decoded TCF source is active (`tcf` or `gpp.eu_tcf`) but @@ -322,7 +346,7 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b /// Returns the effective decoded TCF consent for enforcement decisions. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() @@ -475,46 +499,38 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( +pub fn gate_eids_by_permissions( eids: Option>, - consent_ctx: Option<&ConsentContext>, + permissions: &PermissionState, ) -> Option> { let eids = eids?; if eids.is_empty() { return None; } - let tcf = consent_ctx.and_then(effective_tcf); - - match tcf { - Some(tcf) if allows_eid_transmission(tcf) => Some(eids), - Some(_) => { - log::info!("EIDs stripped: TCF Purpose 1 or 4 consent missing"); - None - } - None => { - // No TCF data — if GDPR applies, block EIDs as a precaution. - if consent_ctx.is_some_and(|c| c.gdpr_applies) { - log::info!("EIDs stripped: GDPR applies but no TCF consent available"); - None - } else { - Some(eids) - } - } + if permissions.is_set(Permission::StoreOnDevice) + && permissions.is_set(Permission::SelectPersonalisedAds) + { + Some(eids) + } else { + log::info!( + "EIDs stripped: necessary.operations.storage or advertising_marketing.first_party.targeted is not set in the resolved permissions" + ); + None } } @@ -522,110 +538,30 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- -/// Determines whether Edge Cookie (EC) creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Returns `true` when the request carries a US-style storage/sale opt-out +/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of +/// jurisdiction. /// -/// The decision follows the jurisdiction's consent model: +/// This reports the signal only. Whether the opt-out changes a permission is +/// decided by the country/region map when the permission state is assembled: it +/// drops a `granted` baseline (for example a US opt-out state) and has nothing to +/// drop where the permission is `requires_signal`. Honoring it everywhere is +/// intentionally conservative. /// -/// - **GDPR (EU/UK)**: opt-in required — TCF Purpose 1 (store/access -/// information on a device) must be explicitly consented. If no TCF data is -/// available under GDPR, consent is assumed absent and EC is blocked. -/// - **US state privacy**: opt-out model — EC is allowed unless the user has -/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or -/// the US Privacy string. Explicit US opt-out signals take precedence over -/// TCF storage consent. -/// - **Non-regulated**: EC is allowed (no consent requirement). -/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is -/// blocked as a precaution. +/// TCF consent or refusal is handled separately by +/// [`crate::ec::consent::permission_signal`], which treats a present TCF record +/// as authoritative, so this helper does not consider TCF. #[must_use] -pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of other consent signals. - if ctx.gpc { - return false; - } - // Explicit US opt-out signals take precedence over TCF storage - // consent in US-state jurisdictions. - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return false; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the TCF - // Purpose 1 decision if no explicit US opt-out signal is present. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // GPP US sale_opt_out=false is an explicit non-opt-out signal. - if let Some(gpp) = &ctx.gpp - && let Some(opted_out) = gpp.us_sale_opt_out - { - return !opted_out; - } - // Check US Privacy string when no TCF decision is present. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, +pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { + if ctx.gpc { + return true; } -} - -/// Returns `true` only when the request contains an explicit EC opt-out signal. -/// -/// This is intentionally narrower than [`allows_ec_creation`]. Some requests -/// fail closed because consent cannot be verified yet (for example, missing geo -/// or missing/undecodable consent signals in a regulated jurisdiction). Those -/// cases must block *new* EC creation, but they must not be treated as an -/// authoritative withdrawal of an already-issued EC. -#[must_use] -pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent()) - } - jurisdiction::Jurisdiction::UsState(_) => { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return true; - } - if let Some(tcf) = effective_tcf(ctx) { - return !tcf.has_storage_consent(); - } - false - } - jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false, + if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { + return true; } + ctx.us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) } // --------------------------------------------------------------------------- @@ -701,11 +637,10 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, apply_expiration_check, - apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + ConsentPipelineInput, apply_expiration_check, apply_tcf_conflict_resolution, + build_consent_context, build_context_from_signals, consent_allows_server_side_auction, + gate_eids_by_permissions, has_storage_optout_signal, jurisdiction::Jurisdiction, }; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ ConsentContext, GppConsent, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, }; @@ -894,7 +829,7 @@ mod tests { } #[test] - fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { + fn missing_geo_keeps_unknown_jurisdiction() { let req = build_request(); let config = ConsentConfig::default(); @@ -903,6 +838,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -912,10 +848,6 @@ mod tests { Jurisdiction::Unknown, "missing geo should keep jurisdiction unknown" ); - assert!( - !allows_ec_creation(&ctx), - "missing geo should keep EC creation fail-closed" - ); } #[test] @@ -932,6 +864,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -962,6 +895,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -1071,408 +1005,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // allows_ec_creation tests - // ----------------------------------------------------------------------- - - /// Helper: builds a TCF consent with configurable Purpose 1 (storage). - fn make_tcf_with_storage(has_storage: bool) -> TcfConsent { - TcfBuilder::new().with_storage(has_storage).build() - } - - #[test] - fn ec_allowed_gdpr_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(true)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 consented should allow EC" - ); - } - - #[test] - fn ec_blocked_gdpr_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(false)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 not consented should block EC" - ); - } - - #[test] - fn ec_blocked_gdpr_no_tcf_data() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: None, - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR with no TCF data should block EC" - ); - } - - #[test] - fn ec_allowed_gdpr_via_gpp_embedded_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf_with_storage(true)), - us_sale_opt_out: None, - }), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + GPP embedded TCF with P1 consent should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_no_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + no opt-out should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + opt-out should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_implies_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPC=true with no US Privacy string should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_no_signals() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: false, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + no consent signals should block EC (spec \u{a7}6.1.1: fail-closed)" - ); - } - - #[test] - fn ec_allowed_non_regulated() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "non-regulated jurisdiction should always allow EC" - ); - } - - #[test] - fn ec_blocked_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "unknown jurisdiction should block EC (fail-closed when geo unavailable)" - ); - assert!( - !has_explicit_ec_withdrawal(&ctx), - "unknown jurisdiction should not be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC=true should block EC even when US Privacy says no opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPC=true should be treated as an explicit withdrawal signal" - ); - } - - #[test] - fn ec_us_privacy_not_applicable_allows_ec() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("VA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::NotApplicable, - opt_out_sale: PrivacyFlag::NotApplicable, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US Privacy with opt_out=N/A should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_tcf_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)" - ); - } - - #[test] - fn ec_blocked_us_state_tcf_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(false)), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + TCF Purpose 1 denied should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" - ); - } - - #[test] - fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_allowed_us_state_gpp_no_sale_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=false should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpp_sale_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpc: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when GPP US says no opt-out" - ); - } - - #[test] - fn ec_us_state_gpp_us_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPP US opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: None, - us_sale_opt_out: None, - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GPP without US section should fall through to us_privacy" - ); - } - // ----------------------------------------------------------------------- // Consent KV read-fallback / write-on-change pipeline tests // ----------------------------------------------------------------------- @@ -1554,6 +1086,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1585,6 +1118,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1596,6 +1130,7 @@ mod tests { req: &bare_req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1620,6 +1155,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: Some(&store), }); @@ -1629,4 +1165,86 @@ mod tests { "should not persist consent without an EC ID" ); } + + #[test] + fn gate_eids_keeps_eids_when_required_permissions_are_set() { + // US maps to us-opt-out, where necessary.operations.storage and advertising_marketing.first_party.targeted + // are granted with no signal, so bidstream EIDs are transmitted. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_some(), + "EIDs should pass when necessary.operations.storage and advertising_marketing.first_party.targeted are set" + ); + } + + #[test] + fn gate_eids_strips_eids_when_a_required_permission_is_unset() { + // FR maps to gdpr-eu, where every purpose is requires_signal, so with no + // signal neither required permission is set and EIDs are stripped. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("FR"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" + ); + } + + #[test] + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + assert!( + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" + ); + assert!( + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" + ); + } + + #[test] + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); + + let gpp_sale_opt_out = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![8], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" + ); + + let usp_opt_out = ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::No, + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage opt-out" + ); + + assert!( + !has_storage_optout_signal(&ConsentContext::default()), + "no signal is not a storage opt-out" + ); + } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index 73c2bbc3f..2cf8a6a8e 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -149,6 +149,21 @@ pub struct ConsentContext { } impl ConsentContext { + /// Whether any consent record is present in raw form but failed to decode. + /// + /// A malformed record is not the same as no record: the visitor expressed + /// a preference that could not be read, so the permission mapping blocks + /// baseline grants (fail-closed) instead of degrading to the no-signal + /// baseline. An expired TCF record is excluded because expiry is its own + /// explicit state ([`expired`](Self::expired)): the raw string is kept for + /// proxy forwarding while the decoded record is deliberately cleared. + #[must_use] + pub fn has_malformed_record(&self) -> bool { + (self.raw_tc_string.is_some() && self.tcf.is_none() && !self.expired) + || (self.raw_gpp_string.is_some() && self.gpp.is_none()) + || (self.raw_us_privacy.is_some() && self.us_privacy.is_none()) + } + /// Returns `true` when no consent signals are present. #[must_use] pub fn is_empty(&self) -> bool { diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index 465629a51..a20b187e6 100644 --- a/crates/trusted-server-core/src/consent_config.rs +++ b/crates/trusted-server-core/src/consent_config.rs @@ -12,27 +12,6 @@ const MAX_CONSENT_AGE_DAYS: u32 = 395; /// How many days newer one string must be to win under the `newest` strategy. const FRESHNESS_THRESHOLD_DAYS: u32 = 30; -/// EU member states (27) + EEA non-EU (3) + UK GDPR (1). -/// -/// Switzerland (`CH`) is intentionally excluded: the Swiss FADP mirrors GDPR -/// but is a separate legal regime. Publishers operating in Switzerland can add -/// `CH` to `consent.gdpr.applies_in` in their configuration. -const GDPR_COUNTRIES: &[&str] = &[ - "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", - "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", "GB", -]; - -/// US states with active comprehensive privacy laws (as of 2026). -const US_PRIVACY_STATES: &[&str] = &[ - "CA", "VA", "CO", "CT", "UT", "MT", "OR", "TX", "FL", "DE", "IA", "NE", "NH", "NJ", "TN", "MN", - "MD", "IN", "KY", "RI", -]; - -/// Converts a static `&[&str]` slice to an owned `Vec`. -fn str_vec(codes: &[&str]) -> Vec { - codes.iter().copied().map(String::from).collect() -} - /// Top-level consent configuration (`[consent]` in TOML). #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -56,14 +35,6 @@ pub struct ConsentConfig { #[serde(default = "default_max_consent_age_days")] pub max_consent_age_days: u32, - /// GDPR jurisdiction configuration. - #[serde(default)] - pub gdpr: GdprConfig, - - /// US state privacy law configuration. - #[serde(default)] - pub us_states: UsStatesConfig, - /// Defaults for constructing a US Privacy string when only `Sec-GPC` /// is present and no explicit `us_privacy` cookie exists. #[serde(default)] @@ -86,8 +57,6 @@ impl Default for ConsentConfig { mode: ConsentMode::Interpreter, check_expiration: true, max_consent_age_days: MAX_CONSENT_AGE_DAYS, - gdpr: GdprConfig::default(), - us_states: UsStatesConfig::default(), us_privacy_defaults: UsPrivacyDefaultsConfig::default(), conflict_resolution: ConflictResolutionConfig::default(), consent_store: None, @@ -165,55 +134,6 @@ impl ConsentForwardingMode { } } -// --------------------------------------------------------------------------- -// GDPR -// --------------------------------------------------------------------------- - -/// GDPR jurisdiction configuration (`[consent.gdpr]`). -/// -/// The `applies_in` list is used for **observability and logging only** — it -/// does NOT cause consent to be synthesized. When a user's country appears in -/// this list, the system logs that GDPR applies, enabling publishers to -/// monitor jurisdiction coverage. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct GdprConfig { - /// ISO 3166-1 alpha-2 country codes where GDPR applies. - #[serde(default = "default_gdpr_countries")] - pub applies_in: Vec, -} - -impl Default for GdprConfig { - fn default() -> Self { - Self { - applies_in: str_vec(GDPR_COUNTRIES), - } - } -} - -// --------------------------------------------------------------------------- -// US States -// --------------------------------------------------------------------------- - -/// US state privacy law configuration (`[consent.us_states]`). -/// -/// Config-driven to avoid recompilation when new state laws take effect. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct UsStatesConfig { - /// US state codes with active comprehensive privacy laws. - #[serde(default = "default_us_privacy_states")] - pub privacy_states: Vec, -} - -impl Default for UsStatesConfig { - fn default() -> Self { - Self { - privacy_states: str_vec(US_PRIVACY_STATES), - } - } -} - // --------------------------------------------------------------------------- // US Privacy defaults (GPC handling) // --------------------------------------------------------------------------- @@ -315,14 +235,6 @@ const fn default_freshness_threshold_days() -> u32 { FRESHNESS_THRESHOLD_DAYS } -fn default_gdpr_countries() -> Vec { - str_vec(GDPR_COUNTRIES) -} - -fn default_us_privacy_states() -> Vec { - str_vec(US_PRIVACY_STATES) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -354,35 +266,6 @@ mod tests { ); } - #[test] - fn default_gdpr_countries_includes_eu_eea_uk() { - let config = ConsentConfig::default(); - let countries = &config.gdpr.applies_in; - assert!( - countries.contains(&"DE".to_owned()), - "should include Germany" - ); - assert!( - countries.contains(&"NO".to_owned()), - "should include Norway (EEA)" - ); - assert!(countries.contains(&"GB".to_owned()), "should include UK"); - assert_eq!( - countries.len(), - 31, - "should have 31 countries (27 EU + 3 EEA + 1 UK)" - ); - } - - #[test] - fn default_us_privacy_states_includes_california() { - let config = ConsentConfig::default(); - assert!( - config.us_states.privacy_states.contains(&"CA".to_owned()), - "should include California" - ); - } - #[test] fn default_us_privacy_defaults_reflect_common_posture() { let config = ConsentConfig::default(); @@ -469,8 +352,6 @@ mod tests { "mode": "interpreter", "check_expiration": false, "max_consent_age_days": 180, - "gdpr": { "applies_in": ["DE", "FR"] }, - "us_states": { "privacy_states": ["CA"] }, "us_privacy_defaults": { "notice_given": false, "lspa_covered": true, @@ -485,8 +366,6 @@ mod tests { serde_json::from_value(json).expect("should deserialize full config"); assert!(!config.check_expiration); assert_eq!(config.max_consent_age_days, 180); - assert_eq!(config.gdpr.applies_in, vec!["DE", "FR"]); - assert_eq!(config.us_states.privacy_states, vec!["CA"]); assert!(!config.us_privacy_defaults.notice_given); assert!(config.us_privacy_defaults.lspa_covered); assert_eq!(config.conflict_resolution.mode, ConflictMode::Newest); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..2e7df513b 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -42,19 +42,31 @@ pub const HEADER_ACCEPT_LANGUAGE: HeaderName = HeaderName::from_static("accept-l pub const HEADER_ACCEPT_ENCODING: HeaderName = HeaderName::from_static("accept-encoding"); pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer"); -/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// The fixed response headers that carry Edge Cookie identity output. /// -/// These headers are used internally by Trusted Server for identification, geo-enrichment, -/// debugging, and compression hints. Leaking them to external origins could expose -/// data and internal implementation details. +/// EC finalization strips these from a response the request was not permitted +/// to carry an identity on (see `clear_ec_headers_on_response` in +/// [`finalize`](crate::ec::finalize)), and they are also internal headers, so +/// [`INTERNAL_HEADERS`] is built from this list rather than repeating it. That +/// is the whole reason the list lives here alongside `INTERNAL_HEADERS` and not +/// beside its only reader, because two hand-written copies of one list drift as +/// soon as a header is added to one of them. /// -/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear -/// in `const` context. -pub const INTERNAL_HEADERS: &[&str] = &[ +/// Uses `&str` slices for the same reason [`INTERNAL_HEADERS`] does. +pub const EC_RESPONSE_HEADERS: &[&str] = &[ "x-ts-ec", "x-ts-eids", "x-ts-ec-consent", "x-ts-eids-truncated", +]; + +/// The internal headers that are not part of the Edge Cookie output surface. +/// +/// Kept apart from [`EC_RESPONSE_HEADERS`] only so [`INTERNAL_HEADERS`] can be +/// assembled from the two without repeating either. Add a header here unless it +/// is one EC finalization has to strip, in which case it belongs in +/// [`EC_RESPONSE_HEADERS`] and reaches [`INTERNAL_HEADERS`] from there. +const NON_EC_INTERNAL_HEADERS: &[&str] = &[ "x-pub-user-id", "x-subject-id", "x-consent-advertising", @@ -76,6 +88,43 @@ pub const INTERNAL_HEADERS: &[&str] = &[ "x-ts-tls-cipher", ]; +/// How many names [`INTERNAL_HEADERS`] holds. +const INTERNAL_HEADER_COUNT: usize = EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(); + +/// Joins the two source lists into the array [`INTERNAL_HEADERS`] borrows. +/// +/// Written as a `const fn` because slice concatenation is not available in a +/// `const` initializer, and the join has to happen while the crate is compiled +/// so no caller pays for it. +const fn join_internal_headers() -> [&'static str; INTERNAL_HEADER_COUNT] { + let mut joined = [""; INTERNAL_HEADER_COUNT]; + let mut i = 0; + while i < EC_RESPONSE_HEADERS.len() { + joined[i] = EC_RESPONSE_HEADERS[i]; + i += 1; + } + let mut j = 0; + while j < NON_EC_INTERNAL_HEADERS.len() { + joined[i + j] = NON_EC_INTERNAL_HEADERS[j]; + j += 1; + } + joined +} + +/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// +/// These headers are used internally by Trusted Server for identification, geo-enrichment, +/// debugging, and compression hints. Leaking them to external origins could expose +/// data and internal implementation details. +/// +/// Built at compile time from [`EC_RESPONSE_HEADERS`] followed by +/// [`NON_EC_INTERNAL_HEADERS`], so an Edge Cookie response header cannot be +/// added to one list and missed in the other. +/// +/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear +/// in `const` context. +pub const INTERNAL_HEADERS: &[&str] = &join_internal_headers(); + // Consent-related cookie names pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; pub const COOKIE_GPP: &str = "__gpp"; @@ -84,3 +133,37 @@ pub const COOKIE_US_PRIVACY: &str = "us_privacy"; // Consent-related header names pub const HEADER_SEC_GPC: HeaderName = HeaderName::from_static("sec-gpc"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_edge_cookie_response_header_is_an_internal_header() { + // These two lists used to be written out by hand in two files, with + // nothing keeping them in step, so a new Edge Cookie response header + // could be stripped by EC finalization and still forwarded to a third + // party. `INTERNAL_HEADERS` is now assembled from + // `EC_RESPONSE_HEADERS`, and this is the assertion that fails if + // anyone goes back to writing them out separately. + for header in EC_RESPONSE_HEADERS { + assert!( + INTERNAL_HEADERS.contains(header), + "`{header}` carries Edge Cookie output, so it must never be forwarded" + ); + } + + assert_eq!( + INTERNAL_HEADERS.len(), + EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(), + "every internal header should come from exactly one of the two source lists" + ); + + for (index, header) in INTERNAL_HEADERS.iter().enumerate() { + assert!( + !INTERNAL_HEADERS[index + 1..].contains(header), + "`{header}` is listed twice, so the two source lists overlap" + ); + } + } +} diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6219af7a9..dfd9434e5 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -32,7 +32,6 @@ use crate::error::TrustedServerError; use crate::openrtb::Eid; use super::eids::{resolve_partner_ids, to_eids}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::kv_backend::EcKvLookup; use super::kv_types::{KvEntry, KvMetadata}; @@ -40,6 +39,7 @@ use super::log_id; use super::prebid_eids::{ analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, }; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::registry::PartnerRegistry; /// Route prefix shared by the cookie-based and explicit-ID lookup routes. @@ -276,13 +276,14 @@ struct SkippedPartnerId { pub fn handle_admin_ec_lookup( kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, + provider: Option<&dyn EdgeCookieProvider>, req: &Request, ) -> Result, Report> { let Some(kv) = kv else { return Ok(admin_ec_lookup_not_supported()); }; - let ec_id = match requested_ec_id(req) { + let ec_id = match requested_ec_id(req, &AcceptedProviders::active(provider)) { Ok(ec_id) => ec_id, Err(response) => return Ok(*response), }; @@ -342,9 +343,17 @@ fn cookie_ec_id(req: &Request) -> Result) -> Result>> { +fn requested_ec_id( + req: &Request, + accepted_providers: &AcceptedProviders<'_>, +) -> Result>> { let remainder = req .uri() .path() @@ -358,10 +367,12 @@ fn requested_ec_id(req: &Request) -> Result &'static str { + "opaque" + } + + fn code(&self) -> super::super::provider::ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &super::super::provider::IdentityInput<'_>, + ) -> Result> + { + Ok(super::super::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn requested_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); + + let ec_id = requested_ec_id(&request, &AcceptedProviders::active(None)) + .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); + + assert_eq!(ec_id, coded, "should look up the identifier as given"); + } + + #[test] + fn requested_ec_id_accepts_the_active_non_hmac_provider_and_rejects_others() { + // The diagnostic must be usable on a deployment whose provider is not + // the built-in HMAC one. Before the dispatch every non-`hmac` code was + // a 400, so an operator could not look up the identifier in the very + // cookie the browser was carrying. + let accepted = AcceptedProviders::active(Some(&OpaqueProvider)); + + let opaque = "t0op~Opaque_Value_MixedCase"; + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); + let ec_id = requested_ec_id(&request, &accepted) + .unwrap_or_else(|_| panic!("should accept the active provider's identifier")); + assert_eq!(ec_id, opaque, "should look up the identifier as given"); + + // A code no configured provider reads stays a 400, even in the built-in + // HMAC shape, so one deployment cannot inspect another's identifiers. + let foreign = format!("t0zz~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{foreign}")); + let response = requested_ec_id(&request, &accepted) + .expect_err("an unread provider code should be rejected"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an unread provider code should be a 400" + ); + } } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 0e0f3b900..39d615f1d 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -20,9 +20,9 @@ use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; use super::auth::authenticate_bearer; -use super::generation::{is_valid_ec_id, normalize_ec_id_for_kv}; use super::kv::{KvIdentityGraph, UpsertResult}; use super::log_id; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; @@ -101,15 +101,17 @@ pub fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, req) + handle_batch_sync_with_writer(kv, registry, rate_limiter, provider, req) } fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { // 1. Authenticate @@ -153,7 +155,12 @@ fn handle_batch_sync_with_writer( } // 4. Process mappings with per-item validation and rejection reasons. - let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings); + let (accepted, errors) = process_mappings( + writer, + &partner.source_domain, + &body.mappings, + &AcceptedProviders::active(provider), + ); let rejected = errors.len(); let status = if rejected > 0 { @@ -183,19 +190,24 @@ fn process_mappings( writer: &dyn BatchSyncWriter, partner_id: &str, mappings: &[SyncMapping], + accepted_providers: &AcceptedProviders<'_>, ) -> (usize, Vec) { let mut accepted: usize = 0; let mut errors = Vec::new(); for (idx, mapping) in mappings.iter().enumerate() { - let ec_id = normalize_ec_id_for_kv(&mapping.ec_id); - if !is_valid_ec_id(&ec_id) { + // The global cookie bounds, then the provider that owns the + // identifier's code, which canonicalizes its own value part and decides + // whether the canonical form is one of its own. A partner echoing back + // an identifier a non-HMAC provider created is accepted here; an + // identifier under a code this deployment does not read is not. + let Some(ec_id) = accepted_providers.canonical_kv_key(&mapping.ec_id) else { errors.push(MappingError { index: idx, reason: REASON_INVALID_EC_ID, }); continue; - } + }; if mapping.partner_uid.trim().is_empty() || mapping.partner_uid.len() > MAX_UID_LENGTH { errors.push(MappingError { @@ -266,17 +278,47 @@ mod tests { use super::*; use std::collections::VecDeque; + use crate::ec::provider::{HmacProvider, IdentityInput, ProviderCode}; use crate::error::TrustedServerError; + use crate::evidence::RequestInfo; use crate::redacted::Redacted; use crate::settings::EcPartner; - // EC ID validation tests are in generation.rs (is_valid_ec_id). - // Verify the import works here with a basic smoke test. - #[test] - fn is_valid_ec_id_smoke_test() { - let valid = format!("{}.ABC123", "a".repeat(64)); - assert!(is_valid_ec_id(&valid)); - assert!(!is_valid_ec_id(&"a".repeat(64))); + /// The built-in provider, standing in for a deployment that selected it. + fn hmac_provider() -> HmacProvider { + HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())) + } + + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } } struct MockRateLimiter { @@ -421,7 +463,7 @@ mod tests { .body(EdgeBody::from("not-json")) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -447,7 +489,7 @@ mod tests { .body(EdgeBody::from(oversized_body)) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -466,7 +508,13 @@ mod tests { mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count successful writes as accepted"); assert_eq!(errors.len(), 2, "should reject invalid mappings only"); @@ -493,7 +541,13 @@ mod tests { mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should keep accepted count before failure"); assert_eq!( @@ -521,7 +575,7 @@ mod tests { .expect("should build test request"); let response = - handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); + handle_batch_sync(&kv, ®istry, &limiter, None, req).expect("should return response"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, @@ -581,7 +635,13 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-2", 101)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 0, "should not accept ineligible mappings"); assert_eq!(errors.len(), 2, "should report both errors"); @@ -591,13 +651,104 @@ mod tests { assert_eq!(errors[1].reason, REASON_INELIGIBLE); } + #[test] + fn process_mappings_accepts_an_identifier_from_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one still + // has to accept the identifiers that provider created. Before the + // dispatch these were rejected outright by the HMAC grammar, so a + // partner could never sync a mapping against them. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let mappings = vec![mapping("t0op~Opaque_Value_MixedCase", "uid-1", 100)]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 1, "the active provider's identifier is accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_rejects_a_code_no_configured_provider_reads() { + // The other side of the dispatch: a code belonging to a provider this + // deployment neither runs nor reads is not an identifier here, whatever + // its shape. + let writer = MockWriter::new(vec![]); + let hmac_shaped = format!("t0zz~{}.ABC123", "a".repeat(64)); + let mappings = vec![ + mapping("t0zz~Opaque_Value", "uid-1", 100), + mapping(&hmac_shaped, "uid-2", 100), + ]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 0, "an unknown provider code is not accepted"); + assert_eq!(errors.len(), 2, "both mappings should be rejected"); + assert!( + errors + .iter() + .all(|error| error.reason == REASON_INVALID_EC_ID), + "should reject as an invalid EC ID, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_canonicalizes_through_the_owning_provider() { + // KV normalization is dispatched the same way as validation. The + // built-in provider lowercases its hash segment, so a partner echoing + // uppercase hex still writes the row created at generation time, while + // the opaque provider's own normalization leaves its value untouched. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let uppercase = format!("hmac~{}.ABC123", "A".repeat(64)); + let provider = hmac_provider(); + let accepted_providers = AcceptedProviders::active(Some(&provider)); + + assert_eq!( + accepted_providers.canonical_kv_key(&uppercase), + Some(format!("hmac~{}.ABC123", "a".repeat(64))), + "the built-in provider should lowercase only its hash segment" + ); + assert_eq!( + AcceptedProviders::active(Some(&OpaqueProvider)) + .canonical_kv_key("t0op~Opaque_Value_MixedCase"), + Some("t0op~Opaque_Value_MixedCase".to_owned()), + "an opaque provider's identifier should be keyed verbatim" + ); + + let mappings = vec![mapping(&uppercase, "uid-1", 100)]; + let (accepted, errors) = + process_mappings(&writer, "partner", &mappings, &accepted_providers); + assert_eq!(accepted, 1, "uppercase hex should still be accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + #[test] fn process_mappings_counts_unchanged_as_accepted() { let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count unchanged mappings as accepted"); assert!( @@ -615,7 +766,13 @@ mod tests { mapping(&ec_id, "uid-old", 100), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!( accepted, 2, @@ -623,4 +780,27 @@ mod tests { ); assert!(errors.is_empty(), "should accept valid mappings"); } + + #[test] + fn process_mappings_accepts_a_minted_coded_ec_id() { + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + // Partners echo the identifier identify gave them, which carries the + // provider-code envelope since the creation path applies it. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let mappings = vec![mapping(&ec_id, "uid-1", 1)]; + + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); + + assert_eq!(accepted, 1, "should accept a coded HMAC identifier"); + assert!( + errors.is_empty(), + "should report no format error for a coded HMAC identifier" + ); + } } diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd29..f87d811d5 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,645 @@ -//! EC-specific consent gating. +//! EC-specific permission gating, resolved through the permission model. //! -//! This module provides the public consent-check API for the EC subsystem. -//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this -//! wrapper exists so that EC callers can import from `ec::consent` and the -//! eventual migration path (renaming, adding EC-specific conditions) is -//! contained here. +//! The Edge Cookie provider advertises the [`Permission`]s its data use +//! requires. [`assemble_permissions`] resolves which permissions are set for a +//! request, from its session signals and the country it maps to, and the +//! context construction gates the provider on that state. The EC permission +//! decision lives here, in the EC subsystem, and nowhere else, so callers +//! route every EC permission check through this module rather than +//! re-deriving one. use crate::consent::ConsentContext; +use crate::consent::jurisdiction::Jurisdiction; +use crate::permissions::{ + Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, + SignalPolicy, +}; +use crate::platform::GeoInfo; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// The outcome of the geo lookup for a request, separating "no location +/// resolved" from "the lookup failed". /// -/// This is the canonical entry point for EC consent checks. It delegates -/// to [`crate::consent::allows_ec_creation`] today but may diverge as -/// EC-specific consent rules evolve. +/// The two must not collapse: with no location (the provider is disabled, or +/// had no data for the address) the permission policy's top node applies, but +/// when the lookup errored the request's place is unknown in a way that top +/// node must not paper over, so every permission resolves to the +/// requires-signal floor instead. +#[derive(Debug, Clone, Copy)] +pub enum GeoStatus<'a> { + /// The provider resolved a location. + Located(&'a GeoInfo), + /// The provider resolved no location, so the policy's top node applies. + NoLocation, + /// The lookup errored, so the requires-signal floor applies. + Failed, +} + +impl<'a> GeoStatus<'a> { + /// The resolved location, when one exists. + #[must_use] + pub fn info(self) -> Option<&'a GeoInfo> { + match self { + GeoStatus::Located(info) => Some(info), + GeoStatus::NoLocation | GeoStatus::Failed => None, + } + } +} + +impl<'a> From> for GeoStatus<'a> { + fn from(geo: Option<&'a GeoInfo>) -> Self { + match geo { + Some(info) => GeoStatus::Located(info), + None => GeoStatus::NoLocation, + } + } +} + +/// The jurisdiction the consent gates apply to a request, from its resolved +/// location or, with none, from the permission policy's top node. +/// +/// The consent gates (for example the server-side auction gate) detect a +/// jurisdiction from geolocation. With no location they would resolve +/// `Unknown` and fail closed even where the policy declares what to do, so the +/// same fallback the permission model applies is offered here: the top node's +/// `jurisdiction` stands in for the missing location. A failed lookup stays +/// unknown, so the consent gates fail closed alongside the requires-signal +/// floor. +#[must_use] +pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { + match geo { + GeoStatus::NoLocation => PermissionMaps::standard().default_jurisdiction(), + GeoStatus::Located(_) | GeoStatus::Failed => Jurisdiction::Unknown, + } +} + +/// Assembles the permission state for a request: the place baseline from the +/// tree in `permissions.yaml`, augmented by the session's signals. +/// +/// Permissions exist without a consent model. With no signal present the result +/// is simply the baseline for the request's country and region. When the geo +/// provider resolves no location, or a country/region that has no rule, the +/// policy's top node applies, and the top node's `group` is required so one is +/// always available. A failed lookup ([`GeoStatus::Failed`]) instead resolves +/// every permission to the requires-signal floor, so an outage is handled +/// protectively rather than as the policy's declared default. +#[must_use] +pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { + let maps = PermissionMaps::standard(); + let signal = permission_signal(consent, maps.signals()); + match geo { + GeoStatus::Failed => PermissionMaps::floor_with(signal), + GeoStatus::Located(_) | GeoStatus::NoLocation => { + let info = geo.info(); + maps.resolve_with( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + signal, + ) + } + } +} + +/// The acquisition rule for Edge Cookie storage in the request's resolved +/// jurisdiction, used to scope destructive withdrawal. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// Resolves the same rules as [`assemble_permissions`] (the request's +/// country/region, the policy's top node when unmatched, and the +/// requires-signal floor when the lookup failed) and returns the rule for +/// [`Permission::StoreOnDevice`]. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { + match geo { + GeoStatus::Failed => Acquisition::RequiresSignal, + GeoStatus::Located(_) | GeoStatus::NoLocation => { + let info = geo.info(); + PermissionMaps::standard() + .rules_or_default( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + ) + .map_or(Acquisition::RequiresSignal, |rules| { + rules.rule_for(Permission::StoreOnDevice) + }) + } + } +} + +/// Maps a consent context to a [`ConsentSignal`] for each permission, applying +/// the [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// +/// This is the only place the EC subsystem reads consent signals. The policy, +/// not this function, decides which sources are authoritative, which TCF purpose +/// maps to which Data Use, and what a US-style opt-out revokes. This function +/// only decodes the request and applies that policy, so no signal-to-permission +/// policy lives in the code. +/// +/// It considers every source the policy names: a TCF record (a standalone TC +/// string or the EU TCF section of a GPP string), and the US-style opt-out +/// signals (GPC, a GPP sale opt-out, or a US Privacy opt-out). Precedence is +/// most-restrictive-first and is fixed in code, not policy: +/// +/// 1. A US-style opt-out revokes the Data Uses the policy lists, even when a +/// TCF record consents. An opt-out is an explicit user signal, so no other +/// signal may override it. +/// 2. A consent record that is present but cannot be decoded revokes +/// everything, so an unreadable expression of preference fails closed +/// instead of degrading to the no-signal baseline. +/// 3. When the policy marks TCF authoritative, a present TCF record then +/// decides the mapped Data Uses: granted where the record consents to the +/// mapped purpose, revoked where it does not, and neutral where no purpose +/// is mapped. The `authoritative` flag governs only whether TCF grants and +/// revokes apply, never whether an opt-out may be overridden. +/// +/// Whether a `Revoke` changes anything is decided by the country/region map, +/// which drops a `granted` baseline and has nothing to drop where the +/// permission is `requires_signal` or `denied`. +fn permission_signal<'a>( + consent: &'a ConsentContext, + signals: &'a SignalPolicy, +) -> impl Fn(Permission) -> ConsentSignal + 'a { + move |permission| { + if opt_out_present(consent, signals.opt_out_sources()) + && signals.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + if consent.has_malformed_record() { + return ConsentSignal::Revoke; + } + if signals.tcf_authoritative() + && let Some(tcf) = crate::consent::effective_tcf(consent) + { + return match signals.tcf_purpose(permission) { + Some(purpose) => { + if tcf.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + ConsentSignal::Revoke + } + } + None => ConsentSignal::Neutral, + }; + } + ConsentSignal::Neutral + } +} + +/// Whether the request carries any of the `sources` a US-style opt-out is +/// declared to use. Decoding only, so the policy (not this function) decides +/// which sources count and what the opt-out revokes. +fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { + sources.iter().any(|source| match source { + OptOutSource::Gpc => consent.gpc, + OptOutSource::GppSaleOptOut => { + consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) + } + OptOutSource::UsPrivacyOptOut => consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), + }) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. /// -/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed -/// result such as unknown jurisdiction or missing consent data must not be -/// treated as an authoritative withdrawal of an already-issued EC. +/// This separates an affirmative withdrawal (which expires the browser cookie +/// and writes the authoritative identity-graph tombstone) from suppression, +/// where the permission is simply not set for this request (which strips EC +/// response headers but must not destroy an already-issued identifier, or a +/// returning user would be permanently withdrawn before they ever get to +/// consent). +/// +/// Only a TCF record refusing storage (Purpose 1) withdraws, and only where +/// the jurisdiction's storage baseline is not `granted`: under a +/// `requires_signal` baseline the refusal is the visitor declining the very +/// signal storage depends on, while under a `granted` baseline storage never +/// depended on the record, so the refusal suppresses use without destroying +/// the identifier. US-style opt-outs (GPC, a GPP sale opt-out, or a US +/// Privacy opt-out) suppress the permissions the policy revokes but are +/// never destructive, and no signal at all is not a withdrawal. #[must_use] -pub fn ec_consent_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +pub fn ec_storage_withdrawn(consent: &ConsentContext, storage_baseline: Acquisition) -> bool { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + return !tcf.has_storage_consent() && !matches!(storage_baseline, Acquisition::Granted); + } + false } #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::TcfConsent; + use crate::test_support::tests::create_test_settings; + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn hmac_provider_is_blocked_without_a_storage_signal() { + let settings = create_test_settings(); + // The test settings select the HMAC provider, which requires + // necessary.operations.storage. The policy's top node resolves storage + // as requires-signal, so with no signal the permission is not set and + // the provider's requirement is not met. + let provider = crate::ec::provider::build_provider(&settings.ec, None, None) + .expect("should build the configured provider") + .expect("should select the hmac provider"); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + assert!( + !state.all_set(provider.required_permissions()), + "the requires-signal default should not satisfy the HMAC provider without a signal" + ); + } + + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + + #[test] + fn no_signal_uses_the_us_opt_out_baseline() { + // US/CA maps to the us-opt-out group, where every purpose is granted + // without a signal, so EC identity and bidstream EIDs are both permitted. + let geo = us_ca_geo(); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "a US opt-out state should grant necessary.operations.storage and advertising_marketing.first_party.targeted" + ); + } + + #[test] + fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline with no jurisdiction match: + // the map granted these purposes, and GPC revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); + } + + // ------------------------------------------------------------------ + // Opt-out precedence pinning tests. These reinstate the behavior the + // consent module enforced before the permission model: an explicit + // opt-out signal suppresses storage and sharing even when a TCF record + // consents. The permission model must never let a CMP-written record + // override the visitor's own opt-out. + // ------------------------------------------------------------------ + + #[test] + fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a US Privacy opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a GPP sale opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::No, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out source should suppress, whatever the others say" + ); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping: only a TCF storage refusal withdraws, and only + // where the baseline did not grant storage outright. Opt-outs suppress + // use but never destroy an already-issued identifier. + // ------------------------------------------------------------------ + + #[test] + fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + assert!( + ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "refusing the signal storage depends on should withdraw" + ); + } + + #[test] + fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::Granted), + "storage never depended on the record here, so refusal suppresses without destroying" + ); + } + + #[test] + fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "a consenting record is not a withdrawal" + ); + } + + #[test] + fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::Granted) + && !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "GPC suppresses use for the request but never destroys the identifier" + ); + } + + #[test] + fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "sale opt-outs suppress use but never destroy the identifier" + ); + } + + #[test] + fn no_signal_is_not_a_withdrawal() { + assert!( + !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), + "absence of a signal must never destroy an identifier" + ); + } + + #[test] + fn a_malformed_record_is_not_a_withdrawal() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "an unreadable record fails closed (suppression), not destructively" + ); + } + + // ------------------------------------------------------------------ + // Malformed-but-present records block baseline grants (fail closed) + // instead of degrading to the no-signal baseline. + // ------------------------------------------------------------------ #[test] - fn ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, + 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 = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + } + #[test] + fn a_malformed_gpp_or_us_privacy_record_is_detected() { + let gpp = ConsentContext { + raw_gpp_string: Some("not-a-gpp-string".to_owned()), + ..ConsentContext::default() + }; + let usp = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" ); } #[test] - fn ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + 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 = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); + } + // ------------------------------------------------------------------ + // Geo status: a failed lookup resolves at the requires-signal floor and + // never consults the tree, while no location resolves at the policy's top + // node. + // ------------------------------------------------------------------ + + #[test] + fn a_failed_geo_lookup_resolves_to_the_requires_signal_floor() { + // The same request, located in a US opt-out state, grants storage + // without a signal. A failed lookup must not reach that rule, or any + // other, so nothing is set without a signal. + let geo = us_ca_geo(); + assert!( + assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)) + .is_set(Permission::StoreOnDevice), + "the located baseline must grant storage, or this test proves nothing" + ); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Failed); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a lookup failure must not fall back to any node of the policy tree" + ); + assert_eq!( + storage_acquisition(GeoStatus::Failed), + Acquisition::RequiresSignal, + "the storage baseline follows the same floor on failure" + ); + } + + #[test] + fn no_location_falls_back_to_the_policy_top_node() { + // The shipped policy's top node is the gdpr-eu group, which requires a + // signal for storage, so an unplaced visitor gets no identifier until + // one arrives. + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + !state.is_set(Permission::StoreOnDevice), + "the top node requires a signal for storage" + ); + assert_eq!( + storage_acquisition(GeoStatus::NoLocation), + Acquisition::RequiresSignal, + "the storage baseline follows the top node on no location" + ); + } + + #[test] + fn no_location_takes_the_jurisdiction_from_the_policy_top_node() { + assert_eq!( + default_jurisdiction(GeoStatus::NoLocation), + Jurisdiction::Gdpr, + "no location should resolve the top node's declared jurisdiction" + ); + assert_eq!( + default_jurisdiction(GeoStatus::Failed), + Jurisdiction::Unknown, + "a failed lookup must not adopt the policy's declared jurisdiction" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // A TCF record now grants or revokes every one of the eleven mapped + // purposes, not only Purpose 1 and Purpose 4. Consent to all purposes + // except Purpose 7 (measure ad performance), in a US opt-out state where + // the baseline granted them all, so a revoke is observable as a drop. + let consented: Vec = (1..=11).filter(|&p| p != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), ..ConsentContext::default() }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + // Purpose 2 is now resolved (it was neutral before), so consent sets it. + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + // Purpose 7 was refused, so the granted baseline is revoked. + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + // The originally wired purposes still behave. assert!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" ); } } diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..08b138389 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,27 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (creation, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator -// (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider created it. +// This is intentionally broader than the built-in format validator +// (`generation::is_valid_ec_id`), which enforces the HMAC provider's +// `<64-hex>.<6-alphanumeric>` structure, either bare or under the `hmac~` +// envelope; an opaque vendor identifier only has to fit the alphabet and the +// length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +59,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +130,41 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn the_ec_cookie_lifetime_is_one_year() { + // The legacy bare-identifier reader's retirement condition (see + // `provider_owns_id`) is written in terms of this lifetime and the + // identity-graph `ENTRY_TTL`, which `kv::tests::constants_have_expected_values` + // pins to the same figure. Changing either moves the earliest safe + // retirement, so neither may drift unnoticed. + assert_eq!( + COOKIE_MAX_AGE, 31_536_000, + "the EC cookie should live one year" + ); + } + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +214,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +281,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586..05f802678 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,9 +1,11 @@ //! Device signal derivation for bot detection and browser classification. //! -//! All functions in this module are pure computations — no KV I/O or Fastly -//! SDK calls. The Fastly adapter extracts raw strings from the request -//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes -//! them here for classification. +//! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection. It +//! reads the [`RequestInfo`] for the User-Agent from the borrowed argument +//! passed to `detect` at call time, and on a host that supplies them the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS and HTTP/2 signals +//! injected into its constructor, then classifies the request from both. //! //! # Signals //! @@ -18,6 +20,8 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; +use crate::evidence::RequestInfo; +use crate::settings::Settings; /// Device signals derived from a single request. /// @@ -33,18 +37,48 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, - /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string. + /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS signal. pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. pub known_browser: Option, + /// Whether the request looks like a real browser, used to gate Edge Cookie + /// writes. Computed by the producing provider: the built-in provider uses a + /// User-Agent-only heuristic, while the Fastly provider strengthens it with + /// the TLS and HTTP/2 signals. + pub looks_like_browser: bool, } impl DeviceSignals { - /// Derives all device signals from raw request data. + /// Derives device signals from the User-Agent alone, with no + /// host-specific TLS or HTTP/2 evidence. + /// + /// This is the default path. It touches no Fastly-specific API, so device + /// classification stays host-neutral by default. `ja4_class` and + /// `h2_fp_hash` are left absent, and the browser/bot decision uses a + /// User-Agent-only heuristic (`looks_like_browser_from_ua`). + #[must_use] + pub fn derive_ua_only(ua: &str) -> Self { + let platform_class = parse_platform_class(ua); + let looks_like_browser = looks_like_browser_from_ua(ua, platform_class.as_deref()); + + Self { + is_mobile: parse_is_mobile(ua), + ja4_class: None, + platform_class, + h2_fp_hash: None, + known_browser: None, + looks_like_browser, + } + } + + /// Derives device signals from the User-Agent strengthened with the + /// host's TLS and HTTP/2 signals. /// /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string - /// from `req.get_client_h2_fingerprint()`. + /// from `req.get_client_h2_fingerprint()`. These signals are + /// host-specific (Fastly), so only the opt-in Fastly device provider + /// uses this path, and the browser/bot gate then requires a TLS signal. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -52,6 +86,12 @@ impl DeviceSignals { let platform_class = parse_platform_class(ua); let h2_fp_hash = h2_fp.map(compute_h2_fp_hash); let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref()); + // The gate strengthened by host signals. A real browser produces a valid + // TLS signal and a recognizable UA platform. Raw HTTP clients + // (curl, Python requests, Go net/http, headless scrapers) lack one or + // both. This is intentionally aimed at filtering obvious missing-signal + // traffic, not at resisting deliberate JA4 + UA spoofing. + let looks_like_browser = ja4_class.is_some() && platform_class.is_some(); Self { is_mobile, @@ -59,32 +99,10 @@ impl DeviceSignals { platform_class, h2_fp_hash, known_browser, + looks_like_browser, } } - /// Returns `true` when the request looks like a real browser. - /// - /// Checks for the presence of recognizable signals rather than matching - /// against a hardcoded signal allowlist. Real browsers always - /// produce a valid TLS probabilistic identifier (`ja4_class`) and a recognizable UA - /// platform string (`platform_class`). Raw HTTP clients (curl, Python - /// requests, Go net/http, headless scrapers) typically lack one or both. - /// - /// # Threat model - /// - /// This heuristic is intentionally aimed at filtering obvious - /// missing-signal traffic, not at resisting deliberate spoofing. A bot - /// that forges plausible JA4 and UA inputs may still pass; deeper - /// consistency checks can be added later if product requirements demand - /// stronger spoof resistance. - /// - /// `known_browser` is still computed and stored on [`KvDevice`] for - /// analytics but does not gate identity operations. - #[must_use] - pub fn looks_like_browser(&self) -> bool { - self.ja4_class.is_some() && self.platform_class.is_some() - } - /// Converts these signals into a [`KvDevice`] for KV storage. #[must_use] pub fn to_kv_device(&self) -> KvDevice { @@ -98,6 +116,90 @@ impl DeviceSignals { } } +/// A strategy for classifying a request into [`DeviceSignals`]. +/// +/// Implementations are selected by configuration. The built-in +/// [`BuiltinDeviceProvider`] is the default; a deployment can switch to another +/// provider without changing call sites. +/// +/// These signals serve identity gating and bot detection, not bid enrichment. +/// [`DeviceSignals`] deliberately carries only the coarse browser and bot +/// classification the Edge Cookie gate needs, not a full device-detection +/// result such as make, model, OS version, or screen size. A richer device +/// model for the ad request is a separate concern. +pub trait DeviceProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Classifies the request into [`DeviceSignals`], reading the request data + /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any + /// host signals injected into its constructor). + /// + /// Device signals gate identity operations and must always yield a value, + /// so this is infallible: a provider that cannot determine a signal returns + /// the unknown variant rather than failing the request. + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the built-in User-Agent-only provider requires + /// no permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } +} + +/// The built-in device provider, the default. +/// +/// Derives [`DeviceSignals`] from the User-Agent alone via +/// [`DeviceSignals::derive_ua_only`], touching no host-specific API. It reads +/// only [`RequestInfo::user_agent`] and never a host signal, so device +/// classification stays host-neutral by default. +#[derive(Debug, Default)] +pub struct BuiltinDeviceProvider; + +impl BuiltinDeviceProvider { + /// Creates the built-in provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl DeviceProvider for BuiltinDeviceProvider { + fn id(&self) -> &'static str { + "builtin" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } +} + +/// Selects the device provider named by the `[device] provider` selector. +/// +/// Returns the built-in User-Agent-only provider unless the `fastly` selector is +/// set, in which case it builds the host-specific provider through the +/// `build_fastly` factory the adapter supplies. The factory runs only when that +/// provider is selected, so device classification itself reads no host signals +/// by default (see [`BuiltinDeviceProvider`] for the host-neutral default). The +/// Fastly entry point still reads the TLS and HTTP/2 signals on every request to +/// build the host-signal service and client info. A +/// selected-but-unknown provider is rejected at startup by +/// [`DeviceConfig::validate_provider_selection`](crate::settings::DeviceConfig::validate_provider_selection), +/// so this falls back to the built-in provider for that case. +#[must_use] +pub fn build_device_provider( + settings: &Settings, + build_fastly: impl FnOnce() -> Box, +) -> Box { + match settings.device.provider_key() { + "fastly" => build_fastly(), + _ => Box::new(BuiltinDeviceProvider::new()), + } +} + /// Device is a desktop (confirmed via UA platform token). const MOBILE_DESKTOP: u8 = 0; /// Device is a mobile (confirmed via UA mobile token). @@ -146,6 +248,53 @@ fn parse_platform_class(ua: &str) -> Option { None } +/// Decides whether a request looks like a real browser from the User-Agent +/// alone, with no TLS or HTTP/2 evidence. +/// +/// A real browser sends the `Mozilla/` token every major engine still emits and +/// a recognizable platform string (so `platform_class` is present), and is not +/// an obvious bot or command-line client. Raw HTTP clients (curl, Python +/// requests, Go net/http) carry no platform token, so they fail the +/// `platform_class` check; declared crawlers are caught by [`looks_like_bot_ua`]. +/// +/// # Threat model +/// +/// This is the default, host-neutral gate. It filters obvious non-browser +/// traffic but does not resist a bot that forges a complete browser +/// User-Agent. The opt-in Fastly device provider strengthens the gate with the +/// TLS and HTTP/2 signals for deployments that need it. +#[must_use] +fn looks_like_browser_from_ua(ua: &str, platform_class: Option<&str>) -> bool { + platform_class.is_some() && ua.contains("Mozilla/") && !looks_like_bot_ua(ua) +} + +/// Returns `true` when the User-Agent declares a known bot, crawler, or +/// non-browser HTTP client. +/// +/// Matches common self-identifying markers case-insensitively. The `bot` marker +/// covers `Googlebot`, `bingbot`, and similar; the library markers cover HTTP +/// clients that set a recognizable platform token. +#[must_use] +fn looks_like_bot_ua(ua: &str) -> bool { + const BOT_MARKERS: &[&str] = &[ + "bot", + "crawl", + "spider", + "slurp", + "curl", + "wget", + "python-requests", + "go-http-client", + "okhttp", + "java/", + "headlesschrome", + "phantomjs", + "scrapy", + ]; + let lower = ua.to_ascii_lowercase(); + BOT_MARKERS.iter().any(|marker| lower.contains(marker)) +} + /// Extracts Section 1 from a full JA4 string. /// /// JA4 format: `section1_section2_section3` separated by underscores. @@ -164,7 +313,7 @@ fn extract_ja4_section1(full_ja4: &str) -> Option { } /// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2 -/// SETTINGS string. +/// SETTINGS signal string. /// /// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`. #[must_use] @@ -191,7 +340,7 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true), ]; -/// Returns H2 SETTINGS hashes for the known browser allowlist. +/// Returns H2 signal hashes for the known browser allowlist. /// /// Computed once on first call and cached via `OnceLock`. fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> { @@ -230,6 +379,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -366,7 +516,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Chrome signals should be recognized" + "Chrome signal should be recognized" ); } @@ -377,7 +527,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Safari signals should be recognized" + "Safari signal should be recognized" ); } @@ -388,7 +538,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Firefox signals should be recognized" + "Firefox signal should be recognized" ); } @@ -523,7 +673,7 @@ mod tests { Some("1:65536;2:0;4:6291456;6:262144"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "Chrome/Mac should look like a browser" ); } @@ -537,8 +687,8 @@ mod tests { Some("99:99;88:88"), ); assert!( - signals.looks_like_browser(), - "unknown signal combination with valid JA4 + platform should pass" + signals.looks_like_browser, + "unknown signal with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); } @@ -547,17 +697,17 @@ mod tests { fn looks_like_browser_rejects_bot() { let signals = DeviceSignals::derive(BOT_UA, None, None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "bot with no JA4 and no platform should be rejected" ); } #[test] fn looks_like_browser_rejects_missing_ja4() { - // Real UA but no JA4 value (e.g. HTTP/1.1 or missing SDK support) + // Real UA but no TLS signal (e.g. HTTP/1.1 or missing SDK support) let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536")); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "missing JA4 should be rejected even with valid UA" ); } @@ -567,8 +717,138 @@ mod tests { // Has JA4 but unrecognizable UA let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "unrecognizable UA should be rejected even with JA4" ); } + + #[test] + fn derive_ua_only_accepts_real_browsers_without_fingerprints() { + for ua in [ + CHROME_MAC_UA, + SAFARI_IOS_UA, + FIREFOX_MAC_UA, + CHROME_ANDROID_UA, + CHROME_WINDOWS_UA, + ] { + let signals = DeviceSignals::derive_ua_only(ua); + assert!( + signals.looks_like_browser, + "a real browser UA should pass the UA-only gate: {ua}" + ); + assert!( + signals.ja4_class.is_none() && signals.h2_fp_hash.is_none(), + "the UA-only path must not record any TLS/H2 evidence" + ); + } + } + + #[test] + fn derive_ua_only_rejects_bots_and_http_clients() { + // Declared crawlers and CLI/library clients must not pass the gate. + for ua in [ + BOT_UA, + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "curl/8.4.0", + "python-requests/2.31.0", + "Go-http-client/2.0", + "", + ] { + assert!( + !DeviceSignals::derive_ua_only(ua).looks_like_browser, + "a non-browser client should fail the UA-only gate: {ua:?}" + ); + } + } + + #[test] + fn derive_ua_only_rejects_a_browser_ua_that_declares_a_bot() { + // Newer crawlers send a full browser UA with a platform token; the bot + // marker must still reject them. + let googlebot_mobile = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36 \ + (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + assert!( + !DeviceSignals::derive_ua_only(googlebot_mobile).looks_like_browser, + "a browser-shaped UA declaring Googlebot should be rejected" + ); + } + + #[test] + fn builtin_device_provider_is_ua_only() { + let provider = BuiltinDeviceProvider::new(); + assert_eq!(provider.id(), "builtin"); + + // The built-in provider classifies from the User-Agent in the request + // info passed to `detect` alone, recording no host signal. + let request_info = request_info_with_ua(CHROME_MAC_UA); + let signals = provider.detect(&request_info); + assert_eq!( + signals, + DeviceSignals::derive_ua_only(CHROME_MAC_UA), + "the built-in provider should classify from the User-Agent only" + ); + assert!( + signals.ja4_class.is_none(), + "the built-in provider must not record a JA4 class" + ); + } + + /// Builds request info carrying the given User-Agent, for provider tests. + fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_str(user_agent) + .expect("should build a valid User-Agent header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + /// A stand-in for the host-specific provider the adapter injects, so the + /// selection logic can be tested in core without the Fastly provider crate. + struct StubFastlyProvider; + + impl DeviceProvider for StubFastlyProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, _request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only("") + } + } + + #[test] + fn builtin_device_provider_requires_no_permissions() { + assert!( + BuiltinDeviceProvider::new() + .required_permissions() + .is_empty(), + "the built-in User-Agent-only device provider requires no permissions" + ); + } + + #[test] + fn build_device_provider_defaults_to_builtin_and_selects_injected() { + // The default selector returns the built-in provider, ignoring the + // injected candidate. + let settings = crate::settings::Settings::default(); + let default = build_device_provider(&settings, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!(default.id(), "builtin", "no selector should be UA-only"); + + // The `fastly` selector returns the provider the adapter's factory builds. + let mut fastly = crate::settings::Settings::default(); + fastly.device.provider = Some("fastly".to_owned()); + let selected = build_device_provider(&fastly, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!( + selected.id(), + "fastly", + "the fastly selector should use the injected provider" + ); + } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..191bb7c84 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,33 +8,29 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; +use super::provider::apply_provider_response_headers; use super::registry::PartnerRegistry; -/// TS-managed response headers tied to EC identity output. -const EC_RESPONSE_HEADERS: &[&str] = &[ - "x-ts-ec", - "x-ts-eids", - "x-ts-ec-consent", - "x-ts-eids-truncated", -]; - /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved permission state, cookie reconciliation, Prebid EID +/// ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,32 +43,47 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. Each + // one was checked against core's reserved response surface at capture + // time in `EcContext::generate_with_provider`, so nothing here can set a + // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop + // header. They accumulate with whatever the origin returned rather than + // replacing it, for the reasons on + // `provider::apply_provider_response_headers`. + apply_provider_response_headers( + response.headers_mut(), + ec_context.response_headers().iter().cloned(), + ); + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (the permission is simply not set) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_context.storage_withdrawn() && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. - let ids_to_withdraw = withdrawal_ec_ids(ec_context); + let keys_to_withdraw = withdrawal_kv_keys(ec_context); // The identity-graph tombstone is the authoritative withdrawal marker // for subsequent EC behavior. if let Some(graph) = kv { - apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| { - if let Err(err) = graph.write_withdrawal_tombstone(ec_id) { + apply_withdrawal_tombstones(&keys_to_withdraw, |kv_key| { + if let Err(err) = graph.write_withdrawal_tombstone(kv_key) { log::error!( "Failed to write withdrawal tombstone for EC ID '{}': {err:?}", - log_id(ec_id), + log_id(kv_key), ); } }); @@ -82,10 +93,14 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { - if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { + // Key EID ingestion by the provider's canonical form of the identifier, + // the key the identity-graph row is stored under, so an ingested EID + // lands on the live row rather than creating a second one keyed by the + // value the browser carries. + if let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) { + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); } // Ordinary returning-user page views no longer refresh the browser @@ -97,12 +112,15 @@ pub fn ec_finalize_response( // there is no KV graph: that would mint a browser cookie with no backing // identity-graph row, producing a phantom ID on later requests. if ec_context.ec_generated() { - let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else { - log::info!("Skipping generated EC response write because KV graph is unavailable"); + let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) else { + log::info!( + "Skipping generated EC response write because the KV graph or the \ + identity-graph key is unavailable" + ); return; }; - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); set_ec_cookie_on_response(settings, ec_context, response); } } @@ -152,30 +170,35 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response HashSet { - let mut hashes = HashSet::new(); - - if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) - { - hashes.insert(cookie_ec_id.to_owned()); +/// The identity-graph keys a withdrawal must tombstone. +/// +/// Both the `ts-ec` cookie the request carried and the active identifier are +/// turned into keys by the provider that owns them, so the tombstone lands on +/// the row the live identifier is stored under rather than on the raw cookie +/// value. An identifier no provider this deployment reads owns produces no key +/// and is dropped, which is the same filtering the previous shape check did. +/// The two collapse to one key when they are the same identity written two +/// ways. +fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { + let mut keys = HashSet::new(); + + if let Some(cookie_kv_key) = ec_context.cookie_ec_kv_key() { + keys.insert(cookie_kv_key); } - if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) - { - hashes.insert(active_ec_id.to_owned()); + if let Some(active_kv_key) = ec_context.ec_kv_key() { + keys.insert(active_kv_key); } - hashes + keys } -fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F) +fn apply_withdrawal_tombstones(kv_keys: &HashSet, mut write_tombstone: F) where F: FnMut(&str), { - for ec_id in ec_ids { - write_tombstone(ec_id); + for kv_key in kv_keys { + write_tombstone(kv_key); } } @@ -219,6 +242,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +256,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +266,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,7 +274,52 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, + ) + } + + /// The identifier [`CanonicalizingProvider`] creates, as the browser carries + /// it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn canonicalizing_context( + ec_was_present: bool, + ec_generated: bool, + consent: ConsentContext, + ec_allowed: bool, + ) -> EcContext { + make_context_with_consent( + Some(CANONICAL_COOKIE_VALUE), + Some(CANONICAL_COOKIE_VALUE), + ec_was_present, + ec_generated, + consent, + ec_allowed, ) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )) + } + + fn graph_with_live_canonical_row() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("finalize-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + graph } fn sample_ec_id(suffix: &str) -> String { @@ -273,11 +344,18 @@ mod tests { } #[test] - fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { + fn withdrawal_kv_keys_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should include exactly one EC ID"); assert!( @@ -287,7 +365,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_deduplicates_matching_cookie_and_active_ec() { + fn withdrawal_kv_keys_deduplicates_matching_cookie_and_active_ec() { let ec_id = sample_ec_id("same01"); let ec_context = make_context( Some(&ec_id), @@ -295,16 +373,17 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should deduplicate identical EC IDs"); assert!(ids.contains(&ec_id), "should retain the shared EC ID"); } #[test] - fn withdrawal_ec_ids_includes_both_cookie_and_active_when_different() { + fn withdrawal_kv_keys_includes_both_cookie_and_active_when_different() { let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); let ec_context = make_context( @@ -313,9 +392,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 2, "should include both distinct EC IDs"); assert!(ids.contains(&active_ec), "should include active EC ID"); @@ -323,7 +403,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_filters_invalid_values() { + fn withdrawal_kv_keys_filters_invalid_values() { let valid_ec = sample_ec_id("valid1"); let ec_context = make_context( Some(&valid_ec), @@ -331,9 +411,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should ignore malformed EC values"); assert!(ids.contains(&valid_ec), "should keep the valid EC ID"); @@ -395,14 +476,17 @@ mod tests { fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); + // A TCF record refusing storage is the withdrawal trigger. The test + // context resolves the storage baseline at the requires-signal floor, + // where refusing the signal storage depends on is destructive. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -448,6 +532,65 @@ mod tests { ); } + /// A decoded TCF record refusing every purpose, storage included. + fn refusing_tcf() -> crate::consent::TcfConsent { + crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { + // A US-style opt-out suppresses use (headers cleared, nothing egressed) + // but is never destructive: the browser cookie is not expired, so a + // visitor who later withdraws the opt-out keeps their identity. + let settings = create_test_settings(); + let ec_id = sample_ec_id("aBc123"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", "stale"); + + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + None, + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "x-ts-ec").is_none(), + "the opt-out should clear the EC header" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "the opt-out should not expire the browser cookie" + ); + } + #[test] fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); @@ -459,6 +602,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +637,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +672,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +700,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +725,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +739,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +758,421 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" ); assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" + ); + } + + #[test] + fn closed_permission_gate_writes_no_ec_cookie() { + // The gate: with the permission gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The permission model is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "set-cookie").is_none(), + "a closed permission gate must not write a ts-ec cookie" + ); + } + + #[test] + fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { + // The tombstone is the authoritative revocation marker, so it has to + // land on the key the live row uses. Written under the raw cookie + // value it creates a second row nothing reads, and the revocation + // never takes effect for a provider whose canonical form differs. + // + // Destructive withdrawal is narrow, so the trigger here is a TCF record + // refusing storage, the same one + // `finalize_withdrawal_clears_cookie_and_headers` uses. An opt-out such + // as GPC suppresses use without destroying an issued identifier, so it + // would write no tombstone for this test to place. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, false); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let (live_row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert!( + !live_row.consent.ok, + "withdrawal should tombstone the row the live identifier is keyed by" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "withdrawal should not write a tombstone under the raw cookie value" + ); + } + + #[test] + fn eid_ingestion_keys_by_the_providers_canonical_form() { + // An ingested EID must join the row the identifier already has. Keyed + // by the raw cookie value the upsert finds no row and the partner ID + // is dropped. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, true); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + ®istry, + None, + Some("shared-cookie-id"), + &mut response, + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert_eq!( + row.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some("shared-cookie-id"), + "the ingested EID should land on the row keyed by the canonical form" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "EID ingestion should not create a row under the raw cookie value" + ); + } + + /// A provider that sets one cookie of its own and one `Vary` entry, the + /// two response effects a provider realistically asks for, so a test can + /// watch both land on a response the origin already wrote headers to. + #[derive(Debug)] + struct EvidenceHeaderProvider; + + impl crate::ec::provider::EdgeCookieProvider for EvidenceHeaderProvider { + fn id(&self) -> &'static str { + "evidence-header" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0eh") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie { + id: Some("evidence-id".to_owned()), + response_headers: vec![ + ( + http::header::SET_COOKIE, + HeaderValue::from_static("vendor-ev=abc; Path=/"), + ), + (http::header::VARY, HeaderValue::from_static("sec-ch-ua")), + ], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn provider_response_headers_reach_the_response_without_dropping_the_origins() { + // The response finalization runs on is the finished one, so it already + // carries the publisher origin's own headers. A provider effect must + // add to those, never replace them: replacing `Set-Cookie` would drop + // the publisher's session and sign-in cookies, and replacing `Vary` + // would break the caching the origin asked for. + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("finalize-provider-headers-store"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) + .with_provider_for_test(std::sync::Arc::new(EvidenceHeaderProvider)); + ec_context + .generate_if_needed(&settings, Some(&graph)) + .expect("should create the identifier through the provider"); + + // What the publisher's origin returned, before EC finalization runs. + let mut response = empty_response(); + response.headers_mut().append( + http::header::SET_COOKIE, + HeaderValue::from_static("publisher_session=origin-value; Path=/; HttpOnly"), + ); + response.headers_mut().append( + http::header::VARY, + HeaderValue::from_static("accept-encoding"), + ); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("publisher_session=origin-value")), + "the origin's own cookie must survive a provider effect, got {cookies:?}" + ); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("vendor-ev=abc")), + "the provider's cookie must reach the response, got {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie must still be written, got {cookies:?}" + ); + + let vary: Vec<&str> = response + .headers() + .get_all(http::header::VARY) + .iter() + .map(|value| value.to_str().expect("should render vary as utf-8")) + .collect(); + assert!( + vary.contains(&"accept-encoding"), + "the origin's Vary must survive a provider effect, got {vary:?}" + ); + assert!( + vary.contains(&"sec-ch-ua"), + "the provider's Vary must reach the response, got {vary:?}" + ); + } + + /// A provider standing in for the one a deployment switched *to*, with a + /// different registered code from the provider that created the live row. + #[derive(Debug)] + struct SwitchedProvider; + + impl crate::ec::provider::EdgeCookieProvider for SwitchedProvider { + fn id(&self) -> &'static str { + "switched" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0sw") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn switching_provider_leaves_the_previous_providers_row_beyond_withdrawal() { + // Pins what a provider switch really does, which the switching + // section of the pluggable-providers spec now states plainly. The + // retired provider's identifier is owned by nobody this deployment + // reads, so a later withdrawal expires the browser cookie but cannot + // tombstone the row, and the identifier is never adopted either. If + // the deferred `legacy_providers` reader list ever lands, this test + // is meant to fail, so that the spec sentence a deployer acts on is + // revisited in the same change. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + // A TCF record consenting to nothing, under GDPR, so the request + // carries an explicit refusal of storage. That is the narrow, + // destructive kind of withdrawal, the one that tombstones rather than + // merely suppressing, which is the behavior under test. + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: ConsentSource::Cookie, + ..Default::default() + }; + // The browser still carries the identifier the previous provider + // created, but the deployment now runs a provider with a different + // code, so read-back treats the cookie as absent and the active + // identifier is empty. + let ec_context = make_context_with_consent( + None, + Some(CANONICAL_COOKIE_VALUE), + false, + false, + consent, + false, + ) + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + assert!( + ec_context.ec_value().is_none(), + "the retired provider's identifier must never be adopted by the new one" + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the previous provider's row") + .expect("the previous provider's row should still exist"); + assert!( + row.consent.ok, + "withdrawal cannot reach a retired provider's row without the provider that owns the code" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && cookie.contains("Max-Age=0")), + "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..90530687c 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -10,8 +10,8 @@ use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; +use crate::ec::provider::{HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR, split_provider_code}; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +81,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Creates an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, or TLS and HTTP/2 signal, so distinct part lists +/// cannot collide. A provider that derives identity from multiple request +/// signals (for example a Fastly provider over JA4, H2, IP, and UA) passes them +/// as separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -120,12 +140,33 @@ pub fn ec_hash(ec_id: &str) -> &str { /// so internal EC IDs are already lowercase. This normalization is a /// defense-in-depth measure for EC IDs submitted by external partners /// (via batch sync) that may use uppercase hex. +/// +/// An identifier this function creates carries the built-in provider's code +/// envelope (`hmac~` before the value, see +/// [`PROVIDER_CODE_SEPARATOR`](super::provider::PROVIDER_CODE_SEPARATOR)), +/// and partners echo that form back, so the envelope is kept and only the +/// value inside it is lowercased. That keeps the key identical to the one +/// written at creation. An identifier under any other provider's code is not +/// HMAC-shaped and is returned unchanged, because only that provider knows +/// how to normalize it. #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { - let mut parts = ec_id.splitn(2, '.'); + let (code, bare) = match split_provider_code(ec_id) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => (Some(code), bare), + (Some(_), _) => return ec_id.to_owned(), + (None, bare) => (None, bare), + }; + let mut parts = bare.splitn(2, '.'); let hash = parts.next().unwrap_or_default(); let suffix = parts.next().unwrap_or_default(); - format!("{}.{}", hash.to_ascii_lowercase(), suffix) + match code { + Some(code) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}.{}", + hash.to_ascii_lowercase(), + suffix + ), + None => format!("{}.{}", hash.to_ascii_lowercase(), suffix), + } } /// Checks whether a string is a valid 64-character hex EC hash prefix. @@ -139,17 +180,35 @@ pub fn is_valid_ec_hash(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } -/// Checks whether a string matches the expected EC ID format. +/// Checks whether a string is a built-in HMAC identifier, bare or enveloped. /// -/// The format is `{64hex}.{6alnum}` where the first part is a 64-character -/// **lowercase** hex string and the second part is a 6-character alphanumeric -/// string. Only lowercase hex is accepted; callers must normalize before -/// validation to prevent duplicate KV keys from case-variant EC IDs. The HMAC -/// prefix is lowercase because it comes from `hex::encode`; the random suffix -/// allows mixed-case alphanumeric characters by construction. +/// The bare format is `{64hex}.{6alnum}` where the first part is a +/// 64-character **lowercase** hex string and the second part is a 6-character +/// alphanumeric string. Only lowercase hex is accepted; callers must +/// normalize before validation to prevent duplicate KV keys from case-variant +/// EC IDs. The HMAC prefix is lowercase because it comes from `hex::encode`; +/// the random suffix allows mixed-case alphanumeric characters by +/// construction. +/// +/// An identifier this provider creates carries the provider-code envelope, +/// `hmac~` before the bare value, so both the enveloped and the legacy bare +/// form are accepted here. An identifier under any other provider's code is +/// not an HMAC identifier and is rejected. +/// +/// This is the built-in provider's grammar, not the deployment's. The +/// partner-facing paths (pull sync, batch sync, the admin lookup) dispatch by +/// provider code through +/// [`AcceptedProviders`](super::provider::AcceptedProviders), which reaches +/// this only for an identifier the built-in provider owns, or as the fallback +/// for a stateless deployment that has selected no provider at all. #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { - let mut parts = value.split('.'); + let bare = match split_provider_code(value) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => bare, + (Some(_), _) => return false, + (None, bare) => bare, + }; + let mut parts = bare.split('.'); let Some(hmac_part) = parts.next() else { return false; }; @@ -175,7 +234,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +306,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +315,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), @@ -321,4 +411,37 @@ mod tests { "should reject extra segments" ); } + + #[test] + fn is_valid_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "a".repeat(64)); + assert!( + is_valid_ec_id(&coded), + "should accept a created identifier carrying the hmac code" + ); + } + + #[test] + fn is_valid_ec_id_rejects_other_provider_codes() { + let coded = format!("t0op~{}.ABC123", "a".repeat(64)); + assert!( + !is_valid_ec_id(&coded), + "should reject an identifier carrying another provider's code" + ); + } + + #[test] + fn normalize_ec_id_for_kv_keeps_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "A".repeat(64)); + assert_eq!( + normalize_ec_id_for_kv(&coded), + format!("hmac~{}.ABC123", "a".repeat(64)), + "should lowercase the hash and keep the code prefix" + ); + assert_eq!( + normalize_ec_id_for_kv("t0op~MixedCase"), + "t0op~MixedCase", + "should leave another provider's identifier unchanged" + ); + } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..ca34354a1 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,11 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + // Identify returns the partner's UID for this visitor, which is sharing + // the identity beyond the edge, so it needs the same permission pair as + // bidstream EIDs (storage plus personalised-ad selection), not only the + // provider's storage permission. + if !ec_context.ec_sharing_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -87,40 +90,53 @@ pub fn handle_identify( let mut uid: Option = None; let mut cluster_size: Option = None; - match kv.get(ec_id) { - Ok(Some((entry, generation))) => { - if !entry.consent.ok { - // Tombstone entries preserve the withdrawal signal for 24 hours. - // Do not extract IDs or evaluate cluster size because that would - // write back with the live-entry TTL. - log::trace!("Identify found tombstone for '{}'", log_id(ec_id)); - } else { - // Extract only this partner's UID. - if let Some(partner_uid) = entry.ids.get(&partner.source_domain) - && !partner_uid.uid.is_empty() - { - uid = Some(partner_uid.uid.clone()); - } - - // Evaluate cluster size lazily for identify responses. Existing - // stored cluster_size values are reused without a prefix-list call. - match kv.evaluate_cluster(ec_id, &entry, generation) { - Ok(size) => { - cluster_size = size; + // Read the identity-graph row under the provider's canonical form of the + // identifier, the same key generation wrote, rather than under the value the + // browser carries. The two are the same string for the built-in HMAC + // provider and differ for any provider whose canonical form is not the + // cookie value. `None` means no provider this deployment reads owns the + // identifier, so there is no row to look for and the response is not + // degraded. + if let Some(kv_key) = ec_context.ec_kv_key() { + match kv.get(&kv_key) { + Ok(Some((entry, generation))) => { + if !entry.consent.ok { + // Tombstone entries preserve the withdrawal signal for 24 + // hours. Do not extract IDs or evaluate cluster size because + // that would write back with the live-entry TTL. + log::trace!("Identify found tombstone for '{}'", log_id(&kv_key)); + } else { + // Extract only this partner's UID. + if let Some(partner_uid) = entry.ids.get(&partner.source_domain) + && !partner_uid.uid.is_empty() + { + uid = Some(partner_uid.uid.clone()); } - Err(err) => { - log::warn!("Cluster evaluation failed for '{}': {err:?}", log_id(ec_id)); + + // Evaluate cluster size lazily for identify responses. + // Existing stored cluster_size values are reused without a + // prefix-list call. + match kv.evaluate_cluster(&kv_key, &entry, generation) { + Ok(size) => { + cluster_size = size; + } + Err(err) => { + log::warn!( + "Cluster evaluation failed for '{}': {err:?}", + log_id(&kv_key) + ); + } } } } - } - Ok(None) => {} - Err(err) => { - log::warn!( - "Identify KV read failed for EC ID '{}': {err:?}", - log_id(ec_id) - ); - degraded = true; + Ok(None) => {} + Err(err) => { + log::warn!( + "Identify KV read failed for EC ID '{}': {err:?}", + log_id(&kv_key) + ); + degraded = true; + } } } @@ -332,7 +348,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +367,23 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + /// The identifier [`CanonicalizingProvider`] creates, as the browser + /// carries it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests, which asserts both the key it writes and the key + /// [`EcContext::ec_kv_key`] derives. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +497,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +539,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +564,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +598,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +624,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +677,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +703,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); @@ -756,4 +781,57 @@ mod tests { "should vary on identity request inputs for preflight" ); } + + #[test] + fn handle_identify_reads_the_row_under_the_providers_canonical_key() { + // A provider whose canonical form is not the cookie value keys its row + // under the canonical form at generation. Identify has to look there, + // or every such deployment reads a miss for every request and reports + // no partner UID at all. + let settings = create_test_settings(); + let kv = KvIdentityGraph::in_memory("identify-canonical-store"); + kv.create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let ec_context = make_ec_context(true, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )); + + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .expect("should build identify response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let body = serde_json::from_slice::( + &response.into_body().into_bytes().unwrap_or_default(), + ) + .expect("should decode identify response JSON"); + assert_eq!( + body["ec"], CANONICAL_COOKIE_VALUE, + "should echo the identifier the browser carries, not the graph key" + ); + assert_eq!( + body["uid"], "partner-uid-123", + "should find the row generation keyed by the provider's canonical form" + ); + assert_eq!( + body["degraded"], + serde_json::Value::Bool(false), + "a hit under the canonical key is not a degraded read" + ); + } } diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 3572581ce..14f6f3487 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -712,6 +712,19 @@ impl KvIdentityGraph { } // Compute cluster size via prefix list. + // + // `ec_hash` takes everything before the first `.`, so a coded + // identifier yields `hmac~` and a legacy bare one yields + // ``. Prefix matching is anchored at the start of the key, so + // the two never see each other: while pre-epic bare cookies are still + // being read back, two rows for the same client IP that straddle the + // envelope each count only their own half and `cluster_size` + // under-reports. That is accepted, not a defect to work around here. + // The count is reported in identify responses and gates nothing, and + // bridging it would mean a second prefix scan on every request for the + // whole migration window. See section 3 of the pluggable-providers + // design. Anyone making this count gate a decision has to fix the + // bridge first. let hash_prefix = ec_hash(ec_id); let cluster_size = self.count_hash_prefix_keys(hash_prefix)?; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..5e5d4aeb8 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -15,7 +15,7 @@ //! //! - auth (private) — shared Bearer-token authentication helpers //! - [`generation`] — HMAC-based ID generation, IP normalization, format helpers -//! - [`consent`] — EC-specific consent gating wrapper +//! - [`consent`]: EC-specific permission gating, with consent as one input //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers //! - [`kv`] — KV Store identity graph operations (CAS, tombstones, debounce) //! - [`kv_backend`] — Platform-neutral KV primitives implemented by adapters @@ -45,6 +45,7 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; @@ -60,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +73,13 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; +use crate::permissions::{Acquisition, Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -115,24 +121,6 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } -/// Gets an existing EC ID from the request. -/// -/// Attempts to retrieve an existing EC ID from the `ts-ec` cookie. -/// -/// Returns `None` if the cookie does not contain a valid EC ID. -/// -/// # Errors -/// -/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { - let parsed = parse_ec_from_request(req)?; - let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); - if let Some(ref id) = ec_id { - log::trace!("Existing EC ID found: {}", log_id(id)); - } - Ok(ec_id) -} - /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during @@ -152,6 +140,19 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether the configured Edge Cookie provider's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// augmented by the session's signals. Assembled once at construction and + /// read via [`permissions`](Self::permissions). + permissions: PermissionState, + /// The jurisdiction's acquisition rule for Edge Cookie storage, resolved + /// once at construction and used by + /// [`storage_withdrawn`](Self::storage_withdrawn) to scope destructive + /// withdrawal. Defaults to the requires-signal floor. + storage_acquisition: Acquisition, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +162,27 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured and + /// the request carries no usable identifier, so a no-provider deployment and + /// a returning visitor both clone nothing. A provider reads these through + /// [`RequestInfo`](crate::evidence::RequestInfo) at generate time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -198,15 +220,109 @@ impl EcContext { services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { + Self::read_from_request_with_geo_status( + settings, + req, + services, + consent::GeoStatus::from(geo_info), + ) + } + + /// Reads the EC context, resolving the location through the configured geo + /// provider first. + /// + /// This is the constructor adapters use: it runs the geo lookup itself so + /// a failed lookup is distinguished from "no location resolved". No + /// location falls back to the permission policy's top node, while a + /// failure resolves every permission to the requires-signal floor (see + /// [`consent::GeoStatus`]) and is logged at error level so an outage is + /// visible. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the selected Edge Cookie provider + /// cannot be built, the same as + /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). + pub fn read_from_request_resolving_geo( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + ) -> Result> { + let lookup = services.geo().lookup(services.client_info().client_ip); + let geo_info = match &lookup { + Ok(info) => info.clone(), + Err(error) => { + log::error!( + "geo lookup failed; resolving permissions at the requires-signal floor: {error:?}" + ); + None + } + }; + let status = match (&lookup, &geo_info) { + (Err(_), _) => consent::GeoStatus::Failed, + (Ok(_), Some(info)) => consent::GeoStatus::Located(info), + (Ok(_), None) => consent::GeoStatus::NoLocation, + }; + Self::read_from_request_with_geo_status(settings, req, services, status) + } + + fn read_from_request_with_geo_status( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo_status: consent::GeoStatus<'_>, + ) -> Result> { + let geo_info = geo_status.info(); let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // Take the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier, to read the + // provider's required permissions, and again by generation, which + // reuses this one rather than asking for another. `request_provider` + // hands back the instance the composition root resolved when there is + // one, and otherwise resolves the selection from this request's own + // services, the host signals among them, so a provider built from + // request evidence reads this request's evidence. A provider that needs + // a service the host did not supply fails to resolve, which stops the + // request. + let selected_provider: Option> = + provider::request_provider(&settings.ec, services)?; + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -214,20 +330,39 @@ impl EcContext { .map(generation::normalize_ip); // Build consent context from request-local cookies, headers, and geo. + // Jurisdiction detection follows the permission model's fallback: with + // no location resolved the policy's declared jurisdiction stands in, so + // a deployment that declared one is not treated as unknown, while a + // failed lookup stays unknown so the consent gates fail closed + // alongside the requires-signal floor. let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), req, config: &settings.consent, geo: geo_info, + default_jurisdiction: consent::default_jurisdiction(geo_status), ec_id: None, kv_store: None, }); + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline augmented by the session's + // signals. Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let permissions = consent::assemble_permissions(&consent, geo_status); + let storage_acquisition = consent::storage_acquisition(geo_status); + // With no provider selected nothing may create or use an identifier, so + // the gate is closed rather than open by default. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|selected| permissions.all_set(selected.required_permissions())); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +372,17 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, + permissions, + storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -254,8 +397,13 @@ impl EcContext { /// /// # Errors /// - /// Returns an error if the client IP is unavailable and generation is - /// needed, or if HMAC generation fails. + /// Forwards every error from `generate_with_provider`: the selected provider + /// failing to derive an identifier (which includes a provider that needs the + /// client IP being run on a host that cannot supply one), the provider + /// producing an identifier outside the cookie-safe alphabet or over the + /// length cap, the provider asking for a response header inside core's + /// reserved surface, or persisting the identifier to the KV identity graph + /// failing. pub fn generate_if_needed( &mut self, settings: &Settings, @@ -265,22 +413,127 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: required permissions not set (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + // Whether the client IP is needed is the selected provider's decision, + // not core's. A provider that derives identity from headers, cookies, + // query parameters, or the client reads no IP and must still run on a + // host that cannot supply one. The IP is passed as the documented + // unavailable value, the empty string (see + // [`RequestInfo::client_ip`](crate::evidence::RequestInfo::client_ip)), + // and a provider that needs it refuses there, returning the error to + // the caller. The publisher proxy and integration proxy log it and + // serve the response without an Edge Cookie. + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly, resolved once at read time and threaded + /// here rather than rebuilt. The request evidence captured at read time + /// (client IP, headers, and the URL path and query) is passed borrowed + /// through [`RequestInfo`](crate::evidence::RequestInfo), so a provider can + /// read cookies and request parameters at generate time, and the built-in + /// HMAC provider reads only the client IP. The skip guards (existing EC, + /// permission gate) stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the provider fails to + /// derive an identifier (which for [`HmacProvider`] includes an + /// unavailable client IP), the provider + /// asks for a response header inside core's reserved surface (see + /// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)), + /// or persisting a generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + permissions: Some(&self.permissions), + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP; a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Check every response header the provider asked for against core's + // reserved surface before any of them are kept. A provider may set its + // own cookies and headers, but not a managed `ts-` cookie, a header in + // the `x-ts-` namespace, or a framing or hop-by-hop header. Rejection + // fails the request, matching the identifier-bounds rejection below: + // without it a provider could write `ts-ec` itself and bypass the + // identifier validation and identity-graph row this function enforces. + // Checked before the identifier is read, because a provider can return + // headers with no identifier at all. + for (name, value) in &generated.response_headers { + if let Some(effect) = provider::reserved_response_effect(name, value) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` returned a response header `{name}` that {effect}", + ec_provider.id(), + ), + })); + } + } + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at creation. The cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or \ + outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +550,13 @@ impl EcContext { .as_ref() .map(DeviceSignals::to_kv_device); - if let Err(err) = graph.create_or_revive(ec_value, &entry) { + // Key the identity graph by the provider's canonical form of the + // identifier, so equivalent representations of one identity share + // one row. The built-in normalization lowercases only the HMAC + // hash segment; an opaque vendor provider overrides it to the + // identity function. + let kv_key = crate::ec::provider::provider_kv_key(ec_provider, ec_value); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { log::error!( "Failed to create or revive EC entry for id '{}' after generation: {err:?}", log_id(ec_value), @@ -319,6 +578,84 @@ impl EcContext { self.ec_value.as_deref() } + /// The providers whose identifiers this request's paths accept. + /// + /// Today that is the selected provider alone (see + /// [`AcceptedProviders`](provider::AcceptedProviders) for the + /// `legacy_providers` seam). + #[must_use] + pub(crate) fn accepted_providers(&self) -> provider::AcceptedProviders<'_> { + provider::AcceptedProviders::active(self.selected_provider.as_deref()) + } + + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. The global cookie bounds are checked first, then the + /// provider-specific part is dispatched by the identifier's code. Falls back + /// to the built-in shape when no provider is configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.accepted_providers().accepts(value) + } + + /// The identity-graph key for `value` under the providers this deployment + /// reads. + /// + /// The canonical route from an identifier to a row key. The organic + /// generate, identify and finalize paths turn an identifier into a row key + /// through this (or through [`ec_kv_key`](Self::ec_kv_key), which wraps it), + /// so a provider whose canonical form differs from the cookie value still + /// finds the row it created. The owning provider is picked by the + /// identifier's `{code}~` prefix and supplies the canonical form of its own + /// value part, matching what + /// [`generate_if_needed`](Self::generate_if_needed) wrote at creation. + /// + /// Known gap: pull sync (`ec::pull_sync`) and the admin lookup + /// (`ec::admin`) still key rows by the raw active identifier rather than + /// this canonical form, so for a provider whose canonical form differs from + /// the cookie value they can read or write under the wrong key. The three + /// organic paths were routed through the canonical form (commit + /// `343ac3e`); these two were left keying raw and are tracked as a known + /// issue for a later change. + /// + /// `None` when no provider this deployment reads owns `value`, in which + /// case there is no row to read or write. + #[must_use] + pub(crate) fn kv_key_for(&self, value: &str) -> Option { + self.accepted_providers().canonical_kv_key(value) + } + + /// The identity-graph key for this request's active identifier. + #[must_use] + pub(crate) fn ec_kv_key(&self) -> Option { + self.ec_value().and_then(|value| self.kv_key_for(value)) + } + + /// The identity-graph key for the `ts-ec` cookie the request carried. + /// + /// Withdrawal tombstones the cookie's row as well as the active one, + /// because a stateless deployment leaves [`ec_kv_key`](Self::ec_kv_key) + /// empty while a live row still exists, and the cookie is the only way + /// back to it. + /// + /// This does not reach across a provider switch. An identifier created + /// under a retired provider's `{code}~` prefix is owned by no provider + /// this deployment reads, so [`kv_key_for`](Self::kv_key_for) yields + /// `None` and its row is never tombstoned. Core cannot derive that key, + /// because the canonical form is the owning provider's own normalization. + /// The browser cookie is still expired, since that path keys off the raw + /// cookie rather than off ownership. See the provider-switching section + /// of the pluggable providers design spec for what an operator has to do + /// about it. + #[must_use] + pub(crate) fn cookie_ec_kv_key(&self) -> Option { + self.existing_cookie_ec_id() + .and_then(|value| self.kv_key_for(value)) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +685,9 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed) or + /// [`permissions`](Self::permissions), which are resolved once at + /// construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -365,6 +704,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -383,10 +730,53 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether the configured Edge Cookie provider's required + /// permissions are set for this request. + /// + /// Resolved once at construction through the permission model (see + /// [`consent::assemble_permissions`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed + } + + /// Whether the request carries an explicit signal withdrawing Edge Cookie + /// storage, scoped to the jurisdiction's storage baseline. + /// + /// See [`consent::ec_storage_withdrawn`]: only a TCF record refusing + /// storage withdraws, and only where the storage baseline is not + /// `granted`. Suppression (the permission merely not set) is reported by + /// [`ec_allowed`](Self::ec_allowed) being `false` instead. + #[must_use] + pub fn storage_withdrawn(&self) -> bool { + consent::ec_storage_withdrawn(&self.consent, self.storage_acquisition) + } + + /// Whether the Edge Cookie identifier may be shared beyond the edge for + /// this request: into the bidstream as `user.id`, in a partner identify + /// response, or in a partner sync call. + /// + /// Sharing rides on the same two permissions as bidstream EIDs (see + /// [`crate::consent::gate_eids_by_permissions`]): storage (the identifier + /// exists and is readable) and personalised-ad selection (it is shared to + /// select ads). [`ec_allowed`](Self::ec_allowed) covers only the + /// provider's own requirements, so a storage-only grant keeps first-party + /// use while withholding partner sharing. + #[must_use] + pub fn ec_sharing_allowed(&self) -> bool { + self.ec_allowed() + && self.permissions.is_set(Permission::StoreOnDevice) + && self.permissions.is_set(Permission::SelectPersonalisedAds) + } + + /// Returns the permissions resolved for this request. + /// + /// Assembled once at construction, the country/region baseline augmented by + /// the session's signals. The core gates provider execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +789,76 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Attaches a selected provider to a test-only [`EcContext`]. + /// + /// The production constructor builds the provider from settings and + /// injected services. A test that only needs the provider's identifier + /// semantics (which identifiers it owns, and their canonical key form) + /// takes this shortcut instead. + #[cfg(test)] + #[must_use] + pub fn with_provider_for_test( + mut self, + provider: Arc, + ) -> Self { + self.selected_provider = Some(provider); + self + } + + /// Creates a test-only `EcContext` with the permission gate open. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// the gate closed. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + Self::new_for_test_gated(ec_value, consent, true) + } + + /// Creates a test-only `EcContext` with an explicit permission gate. + /// + /// `ec_allowed` stands in for the permission decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { + let permissions = if ec_allowed { + PermissionState::new( + [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] + .into_iter() + .collect(), + ) + } else { + PermissionState::default() + }; Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, + permissions, + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -445,9 +876,17 @@ impl EcContext { ec_value, ec_generated: false, consent, + ec_allowed: true, + storage_acquisition: Acquisition::default(), + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -461,6 +900,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +908,17 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, + permissions: PermissionState::default(), + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } } @@ -494,6 +942,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::{EcProviderSelection, ProviderCode}; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +962,874 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0cc") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes a + // snapshot of the request headers through generate_with_provider, so a + // provider reads request cookies through it there too; this test + // supplies its own headers directly. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the permission gate is open and generation runs. + /// + /// A test that drives a built-in provider needs this. The test default + /// country is FR, whose baseline requires a signal before + /// `StoreOnDevice` is set, so the built-in providers are gated off + /// without one. A test double declaring no required permission runs + /// either way and can read the request without a location. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_reuses_the_provider_the_composition_root_resolved() { + // Reading EC state runs on every request, and it used to resolve + // `[ec] provider` itself even though the composition root had just + // resolved the same settings, so the provider was built twice per + // request. An adapter now threads the resolved provider through + // `RuntimeServices`, and the context has to take that instance. + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + + let ec_config = settings.ec.clone(); + let resolved = crate::ec::provider::build_reusable_provider( + &ec_config, + None, + Some(Arc::new(OpaqueIdProvider)), + ) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let req = create_test_request(&[]); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let used = ec + .selected_provider + .as_ref() + .expect("the context should hold the selected provider"); + assert!( + Arc::ptr_eq(used, &resolved), + "reading EC state should reuse the provider resolved at startup rather \ + than building a second one for this request" + ); + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case), the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + } + + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ev") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider created should be committed under its code" + ); + } + + /// A provider that creates an opaque, mixed-case, non-HMAC identifier at + /// the edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0so") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("server-opaque")); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge creates one and persists it. + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be created" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + #[test] + fn a_provider_that_reads_no_client_ip_mints_when_the_host_has_none() { + use crate::platform::test_support::noop_services_with_ec_provider_without_client_ip; + + // The requirement for a client IP belongs to the provider that uses + // one, not to core. A provider deriving identity from the request + // query and cookies runs on a host that cannot determine a client IP, + // and receives the documented unavailable value, the empty string. + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + ec.generate_if_needed(&settings, None) + .expect("a provider that reads no client IP should still create an identifier"); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier should be committed with no client IP available" + ); + } + + #[test] + fn the_hmac_provider_refuses_when_the_host_has_no_client_ip() { + // The other half: the built-in provider's only input is the client IP, + // so with none it fails rather than hashing the empty string into an + // identifier every visitor on that host would share. Identity cannot be + // established, so generate_if_needed returns the error, which the + // publisher and integration proxies log before serving the response + // without an Edge Cookie. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("the HMAC provider should refuse without a client IP"); + assert!( + err.to_string().contains("client IP"), + "the error should name the missing client IP, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed when the provider refuses" + ); + } + + /// A provider that creates an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at creation rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0il") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("illegal")); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("an identifier outside the alphabet should be rejected at creation"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a creation rejection" + ); + } + + /// A provider that returns a caller-chosen response header and no + /// identifier, so a test can drive one provider response effect at a time + /// through the organic generate path. + #[derive(Debug)] + struct HeaderSettingProvider { + name: &'static str, + value: &'static str, + mint: bool, + } + + impl EdgeCookieProvider for HeaderSettingProvider { + fn id(&self) -> &'static str { + "header-setting" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0hs") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.mint.then(|| "provider-value".to_owned()), + response_headers: vec![( + http::HeaderName::from_bytes(self.name.as_bytes()) + .expect("should parse header name"), + http::HeaderValue::from_static(self.value), + )], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + fn generate_with_header_setting_provider( + provider: HeaderSettingProvider, + graph: Option<&KvIdentityGraph>, + ) -> (Settings, Result>) { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("header-setting")); + let services = noop_services_with_ec_provider(Arc::new(provider)); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + let outcome = ec.generate_if_needed(&settings, graph).map(|()| ec); + (settings, outcome) + } + + #[test] + fn generate_rejects_a_provider_effect_inside_the_reserved_response_surface() { + // A provider that sets the managed `ts-ec` cookie would bypass core's + // identifier validation and its identity-graph row entirely, so the + // request fails rather than the effect being quietly dropped. The + // provider creates no identifier here, which is exactly the case the + // cookie write would otherwise slip through. + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "ts-ec=forged-value; Path=/", + mint: false, + }, + None, + ); + + let err = outcome.expect_err("a managed cookie effect should fail the request"); + assert!( + err.to_string().contains("header-setting"), + "the error should name the provider, got: {err}" + ); + + // The same for the reserved header namespace and for message framing. + for (name, value) in [("x-ts-ec", "forged"), ("transfer-encoding", "chunked")] { + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name, + value, + mint: false, + }, + None, + ); + assert!( + outcome.is_err(), + "`{name}` is reserved and should fail the request" + ); + } + } + + #[test] + fn generate_applies_a_provider_owned_cookie_to_the_response() { + // The other half of the rule: a provider's own cookie is not core's, so + // it survives generation and reaches the browser response unchanged, + // alongside the managed `ts-ec` cookie core writes itself. + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "acme-evidence=abc123; Path=/; Secure", + mint: true, + }, + Some(&graph), + ); + let ec = outcome.expect("a provider-owned cookie should not fail the request"); + assert_eq!( + ec.ec_value(), + Some("t0hs~provider-value"), + "the identifier should still be committed" + ); + + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + &settings, + &ec, + Some(&graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=abc123")), + "the provider's own cookie should reach the response, got: {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie should still be written, got: {cookies:?}" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + /// + /// Shared with the identify and finalization tests, which need a provider + /// whose canonical key is not the value the browser carries. + #[derive(Debug)] + pub(crate) struct CanonicalizingProvider; + + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ca") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("canonical")); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + // Pin the read-side derivation to the key generation actually wrote. + // Identify, the withdrawal tombstones, and EID ingestion all read the + // row through `ec_kv_key`, so the two must never drift apart. + assert_eq!( + ec.ec_kv_key().as_deref(), + Some("t0ca~mixed.caseid"), + "the read-side key should be the key generation wrote" + ); + } + + #[test] + fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + // Place the request in a US opt-out state, whose baseline grants the + // storage permission with no signal, so the creation runs. + let geo = us_opt_out_geo(); + let req = create_test_request(&[]); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should generate"); + let created = ec.ec_value().expect("should create an identifier"); + assert!( + created.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {created}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + + /// A geo provider whose lookup fails, the state the permission model's + /// fail-closed rule exists for. + /// + /// No geo provider shipped in this workspace can fail: the Fastly SDK's + /// `geo_lookup` returns an `Option`, the Cloudflare provider reads request + /// headers, and the Axum and Spin providers resolve nothing at all. The + /// `Result` on [`PlatformGeo::lookup`] is there for a provider that does + /// its own fallible lookup, so this stands in for one and proves the floor + /// is reached through the seam rather than only from a hand-built status. + #[derive(Debug)] + struct FailingGeo; + + impl crate::platform::PlatformGeo for FailingGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + Err(Report::new(crate::platform::PlatformError::Geo)) + } + } + + /// A location in a US opt-out state, whose group grants every modeled + /// purpose without a signal. Used where a test needs a granted baseline. + fn us_opt_out_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + + #[test] + fn a_geo_provider_failure_resolves_permissions_at_the_requires_signal_floor() { + use crate::permissions::Permission; + use crate::platform::test_support::build_services_with_geo; + + // A located request in a granted-baseline state, so the assertion can + // only pass by the failure reaching the floor rather than a tree node. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = us_opt_out_geo(); + + let granted = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context for a located request"); + assert!( + granted.permissions().is_set(Permission::StoreOnDevice), + "the located baseline must grant storage, or this test proves nothing" + ); + + let services = build_services_with_geo(std::sync::Arc::new(FailingGeo)); + let failed = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .expect("a failed lookup should resolve permissions, not fail the request"); + assert!( + !failed.permissions().is_set(Permission::StoreOnDevice), + "a geo provider failure must resolve at the requires-signal floor" + ); + assert_eq!( + failed.consent().jurisdiction, + crate::consent::jurisdiction::Jurisdiction::Unknown, + "a failed lookup must not adopt the policy's declared jurisdiction" + ); + } + + #[test] + fn the_resolved_place_decides_the_consent_jurisdiction() { + use crate::consent::jurisdiction::Jurisdiction; + + let settings = create_test_settings(); + let req = create_test_request(&[]); + + // No location at all: the policy's top node answers. + let unplaced = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + unplaced.consent().jurisdiction, + Jurisdiction::Gdpr, + "with no place the top node's jurisdiction should apply" + ); + + // A listed US state names itself as the state. + let geo = us_opt_out_geo(); + let located = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + located.consent().jurisdiction, + Jurisdiction::UsState("CA".to_owned()), + "a listed US state should resolve its own state jurisdiction" + ); + } + + #[test] + fn sharing_requires_the_personalised_ads_permission_not_just_storage() { + let mut ec = + EcContext::new_for_test(Some(valid_ec_id("a", "ABC123")), ConsentContext::default()); + ec.permissions = PermissionState::new([Permission::StoreOnDevice].into_iter().collect()); + assert!(ec.ec_allowed(), "the provider gate is open"); + assert!( + !ec.ec_sharing_allowed(), + "storage alone must not allow sharing beyond the edge" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..7aaf6bc34 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,2048 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. The provider is +//! selected by configuration, with no default, and [`build_provider`] is the +//! composition root that builds the selected one. A built-in provider is +//! constructed from its `[ec.providers.]` block, and a vendor provider is +//! taken from the adapter that injected it. A built-in provider that also needs +//! a host service, as the host-signal provider needs the [`HostSignals`] +//! service, is built only on a host that supplies that service. +//! Construction reads configuration and long-lived services, so a selection +//! this deployment cannot satisfy fails at startup rather than leaving it +//! running without an identity. The host-signal provider is the exception: it +//! is built per request from that request's TLS and HTTP/2 signals (see +//! [`is_request_scoped`](EdgeCookieProvider::is_request_scoped)). +//! +//! Request evidence reaches a provider at call time rather than at +//! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], +//! which carries the normalized client IP, the User-Agent and the request +//! headers, for the life of the call, alongside an [`IdentityInput`] holding +//! the request's gating context. A provider reads what it needs and retains +//! nothing. Core snapshots the headers, path and query it lends to the provider +//! at generate time, and the provider itself keeps none of it. +//! +//! [`HmacProvider`] is the built-in server-side implementation. It derives the +//! identifier from the client IP using HMAC over the configured passphrase, the +//! behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::{HostSignals, RequestInfo}; +use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::cookies::ec_id_has_only_allowed_chars; +use super::generation; + +/// The Edge Cookie identity provider a deployment has selected. +/// +/// Deserialized from the `[ec] provider` string, and serialized back to the +/// same string, so the configuration surface is unchanged. Provider names are +/// open-ended (a vendor crate names its own), so every name other than the +/// explicit `"none"` becomes [`Named`](Self::Named) rather than a parse +/// failure, and whether the deployment can actually supply that provider is +/// decided by [`build_provider`]. +/// +/// No individual provider has a variant of its own, the one still built into +/// core included. Every provider is selected the same way, by name, so no +/// caller can be written around one provider being different, and moving the +/// built-in provider out into its own module changes nothing here. +/// +/// This is the one place the selector is spelled. Everything that needs to ask +/// which provider is selected matches on this rather than comparing string +/// literals. +#[derive(Debug, Clone, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[serde(from = "String", into = "String")] +pub enum EcProviderSelection { + /// Explicit statelessness, spelled `"none"`. The same meaning as omitting + /// the selector: no Edge Cookie is created and no provider block may be + /// configured. + None, + + /// A provider selected by name, configured by the matching + /// `[ec.providers.]` block. [`build_provider`] resolves the name to + /// an implementation, whether that implementation is built into core or + /// injected by the adapter. + Named(String), +} + +impl EcProviderSelection { + /// The configuration spelling of explicit statelessness. + pub const NONE_KEY: &'static str = "none"; + + /// The configuration key this selection is written as. + #[must_use] + pub fn key(&self) -> &str { + match self { + Self::None => Self::NONE_KEY, + Self::Named(key) => key, + } + } +} + +impl From<&str> for EcProviderSelection { + fn from(key: &str) -> Self { + match key { + EcProviderSelection::NONE_KEY => Self::None, + other => Self::Named(other.to_owned()), + } + } +} + +impl From for EcProviderSelection { + fn from(key: String) -> Self { + match key.as_str() { + EcProviderSelection::NONE_KEY => Self::None, + _ => Self::Named(key), + } + } +} + +impl From for String { + fn from(selection: EcProviderSelection) -> Self { + match selection { + EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), + EcProviderSelection::Named(key) => key, + } + } +} + +/// The configuration name of the HMAC provider still built into core. +/// +/// The name lives in the same open-ended namespace every vendor provider name +/// comes from, and nothing branches on it outside the resolution in +/// [`build_provider`]. It is also [`HmacProvider::id`]'s return value and +/// [`HMAC_PROVIDER_CODE`]'s text. It goes with that resolution arm when the +/// built-in provider becomes a module of its own. +pub const HMAC_PROVIDER_KEY: &str = "hmac"; + +/// The configuration name of the host-signal provider still built into core. +/// +/// An ordinary name in the same open-ended namespace as [`HMAC_PROVIDER_KEY`], +/// spelled exactly the way a vendor crate spells its own, and nothing branches +/// on it outside the resolution in [`build_provider`]. It is also +/// [`HostSignalProvider::id`]'s return value, and it goes with that resolution +/// arm when the host-signal provider becomes a module of its own. +pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; + +/// The provider names core supplies itself. +/// +/// A name in this list is already taken, so an adapter that injects a provider +/// under one of them has two suppliers claiming a single name and +/// [`build_provider`] refuses the pair rather than picking one. The list holds +/// one entry per resolution arm in [`resolve_named_provider`], so it grows and +/// shrinks with them, and it empties when the providers still built into core +/// become modules like every other provider, at which point no name is +/// reserved and every provider is injected. +const BUILTIN_PROVIDER_KEYS: &[&str] = &[HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY]; + +/// The registry code of the built-in HMAC provider. +/// +/// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the +/// `{code}~` namespace stamped on every identifier the built-in provider +/// creates, and it is what [`generation`] matches when it decides whether an +/// enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER_KEY); + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data reaches a provider through the `request_info` parameter of +/// [`EdgeCookieProvider::generate`], not through this struct and not through +/// anything injected into the provider's constructor. This struct carries only +/// the per-request gating context a provider may read for behavior beyond +/// gating. On the organic request path the gate has confirmed the provider's +/// required permissions are set before `generate` is called. A direct +/// `edge_cookie::generate_ec_id` call, test-only today, reaches `generate` +/// without that gate. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The permissions resolved for this request, when the calling path carries + /// them. A provider reads this only for behavior beyond gating. The main + /// organic path supplies them; the publisher path passes `None`. + pub permissions: Option<&'a PermissionState>, + + /// The request's consent context, when available, for provider-specific + /// logic. The core gates on permissions, not consent, so a provider reads + /// this only to forward or record consent. [`HmacProvider`] ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + /// + /// Core checks every header here against its own reserved response surface + /// (see [`reserved_response_effect`]) before it is applied, so a provider + /// may set its own cookies and headers but cannot reach into the surface + /// core manages. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// The cookie-name namespace Trusted Server manages. +/// +/// Every cookie core writes or reads as part of its own behavior is named +/// `ts-` (`ts-ec` in [`COOKIE_TS_EC`](crate::constants::COOKIE_TS_EC), +/// `ts-eids` in [`COOKIE_TS_EIDS`](crate::constants::COOKIE_TS_EIDS), and +/// `ts-tester` in [`COOKIE_TS_TESTER`](crate::constants::COOKIE_TS_TESTER)), so +/// core defends the whole prefix rather than a list that a new managed cookie +/// would silently outgrow. `sharedId` is deliberately not reserved: core only +/// reads it, and it belongs to the page's own identity stack. +const MANAGED_COOKIE_NAME_PREFIX: &[u8] = b"ts-"; + +/// The response-header namespace Trusted Server reserves for itself. +/// +/// Covers the fixed EC output headers and the per-partner +/// `x-ts-` headers, which is why the prefix is reserved rather +/// than the four names in +/// [`INTERNAL_HEADERS`](crate::constants::INTERNAL_HEADERS). +const RESERVED_RESPONSE_HEADER_PREFIX: &str = "x-ts-"; + +/// Response headers that frame an HTTP message, are hop-by-hop, or govern +/// caching. +/// +/// The hop-by-hop set is RFC 7230 §6.1, plus `content-length`, which frames the +/// body the adapter is about to write, and `cache-control`, which governs +/// whether the response may be cached. A provider that set any of these would +/// be rewriting the response envelope rather than adding evidence to it, and a +/// provider setting `cache-control` could make an identity-bearing response +/// publicly cacheable, so it is reserved with the rest. +const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ + "cache-control", + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Why one provider response header falls inside core's reserved surface. +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +pub enum ReservedResponseEffect { + /// A `Set-Cookie` naming a cookie in the `ts-` namespace core manages. + #[display("sets a cookie in the `ts-` namespace Trusted Server manages")] + ManagedCookie, + + /// A header in the `x-ts-` namespace core emits and strips. + #[display("sets a header in the reserved `x-ts-` namespace")] + ReservedHeader, + + /// A framing, hop-by-hop, or caching header core manages. + #[display("sets a framing, hop-by-hop, or caching header core manages")] + FramingHeader, +} + +/// The cookie name in a `Set-Cookie` value, as raw bytes. +/// +/// Reads the bytes rather than a `&str` so a value that is not valid UTF-8 +/// cannot smuggle a managed cookie name past the check. +fn set_cookie_name(value: &[u8]) -> &[u8] { + let pair_end = value.iter().position(|b| *b == b';').unwrap_or(value.len()); + let pair = &value[..pair_end]; + let name_end = pair.iter().position(|b| *b == b'=').unwrap_or(pair.len()); + pair[..name_end].trim_ascii() +} + +/// Classifies one provider response header against core's reserved surface. +/// +/// Returns `Some` when the header would reach into what core manages, and +/// `None` for everything else, including a provider's own cookie. Providers +/// legitimately need to set cookies of their own (an evidence cookie for a +/// later request, for example), so the rule reserves core's namespace rather +/// than banning `Set-Cookie` outright. +/// +/// A rejected effect fails the request rather than being dropped, because a +/// provider reaching into the reserved surface has broken its contract in the +/// same way as one creating an identifier outside the cookie-safe alphabet, and +/// that already fails the request. Serving the response instead would let a +/// provider set `ts-ec` directly, bypassing core's identifier validation and +/// its requirement that a created identifier have an identity-graph row. +#[must_use] +pub fn reserved_response_effect( + name: &http::HeaderName, + value: &http::HeaderValue, +) -> Option { + let lower = name.as_str(); + if lower == http::header::SET_COOKIE.as_str() { + let cookie_name = set_cookie_name(value.as_bytes()); + if cookie_name.len() >= MANAGED_COOKIE_NAME_PREFIX.len() + && cookie_name[..MANAGED_COOKIE_NAME_PREFIX.len()] + .eq_ignore_ascii_case(MANAGED_COOKIE_NAME_PREFIX) + { + return Some(ReservedResponseEffect::ManagedCookie); + } + return None; + } + if lower.starts_with(RESERVED_RESPONSE_HEADER_PREFIX) { + return Some(ReservedResponseEffect::ReservedHeader); + } + if FRAMING_OR_HOP_BY_HOP_HEADERS.contains(&lower) { + return Some(ReservedResponseEffect::FramingHeader); + } + None +} + +/// Applies a provider's response headers to a response that already carries +/// the publisher origin's own. +/// +/// Every header here accumulates with what the origin returned rather than +/// replacing it, because a provider on this seam only ever adds evidence about +/// the request. It is never correcting the origin's output, so core has no +/// grounds to discard a value it did not write. Working through the headers a +/// provider can actually set: +/// +/// - `Set-Cookie` can never be folded into one field line, so replacing it +/// drops every cookie the origin set, a publisher's session and sign-in +/// cookies included. This is the case the whole rule turns on, because +/// `response_headers` is a list of pairs precisely so a provider can set more +/// than one cookie of its own, and replacing collapses those too. +/// - The list-valued headers a provider realistically sets, `Vary` first among +/// them, mean the union of their field lines. Replacing the origin's +/// `Vary: Accept-Encoding` with the provider's own would break the cache +/// correctness the origin asked for. +/// - The single-valued headers where replacing would be the right answer are +/// exactly the ones a provider must not author at all, and +/// [`reserved_response_effect`] already fails the request for them: core's +/// `x-ts-` namespace, the `ts-` managed cookies, and the framing and +/// hop-by-hop set. +/// +/// So nothing a provider is permitted to set here needs to replace, and +/// accumulating is the direction that cannot silently destroy someone else's +/// header. Appending where one value was wanted leaves a duplicate a reviewer +/// can see; replacing where two were wanted leaves nothing at all. +pub(crate) fn apply_provider_response_headers(headers: &mut http::HeaderMap, provider_headers: I) +where + I: IntoIterator, +{ + for (name, value) in provider_headers { + headers.append(name, value); + } +} + +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in the +/// provider-code registry and never reused. The code appears as the +/// `{code}~` prefix of every identifier the provider creates, so identifiers +/// from different providers can never collide in the cookie, the identity +/// graph, or a withdrawal, and each identifier records which provider +/// created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code when `code` matches the registry format. + /// + /// Returns `None` when `code` is not exactly four characters of `[a-z0-9]`, + /// so a caller that assembles a code from anything other than a literal is + /// handed an answer it has to deal with rather than a panic. Nothing in + /// this function can panic, whatever it is called with and wherever it is + /// called from. + /// + /// Use [`provider_code!`](crate::provider_code) for a literal. That macro + /// runs this check while the crate is compiled, so a malformed code is a + /// build failure and the resulting value needs no unwrapping. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::ec::provider::ProviderCode; + /// + /// assert_eq!(ProviderCode::new("t0ac").map(ProviderCode::as_str), Some("t0ac")); + /// assert_eq!(ProviderCode::new("nope!"), None); + /// ``` + #[must_use] + pub const fn new(code: &'static str) -> Option { + let bytes = code.as_bytes(); + if bytes.len() != 4 { + return None; + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_lowercase() && !b.is_ascii_digit() { + return None; + } + i += 1; + } + Some(Self(code)) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// Builds a [`ProviderCode`] from a constant, checked while the crate is +/// compiled. +/// +/// The check runs inside a `const` block, so a code that is not exactly four +/// characters of `[a-z0-9]` fails the build instead of panicking at run time, +/// and the value the macro produces needs no unwrapping. Every provider code in +/// this workspace is written through this macro, which is what makes +/// [`ProviderCode::new`]'s fallible form safe to hand to anyone else. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::provider_code; +/// +/// assert_eq!(provider_code!("t0ac").as_str(), "t0ac"); +/// ``` +#[macro_export] +macro_rules! provider_code { + ($code:expr) => { + const { + match $crate::ec::provider::ProviderCode::new($code) { + Some(code) => code, + None => panic!("provider code must be exactly four characters of [a-z0-9]"), + } + } + }; +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form so deployed cookies keep working across +/// the migration. +/// +/// # Retiring the legacy bare reader +/// +/// The reader stays until a bare identifier can no longer arrive. A returning +/// visitor's bare cookie is never rewritten into the coded form, and its +/// `COOKIE_MAX_AGE` lifetime in [`cookies`](super::cookies) (one year, not +/// operator-configurable) runs from the moment it was written. The +/// identity-graph row is not fixed the same way: an ordinary page view that +/// ingests `ts-eids` or `sharedId` cookies runs `ingest_eid_cookies` in +/// `ec_finalize_response` (see [`finalize`](super::finalize)), which rewrites +/// the bare-keyed row with a fresh `ENTRY_TTL` in [`kv`](super::kv) (also one +/// year), so the row's clock restarts on each such view. The earliest safe +/// retirement is therefore one year after the last write that could still +/// leave a bare-keyed row, which is the later of the last release that could +/// still create a bare identifier stopping everywhere and the last page view +/// that refreshed such a row, plus however long a deployment's own rollout +/// takes to reach every point of presence. +/// +/// The other half of that condition, evidence that bare identifiers really +/// have stopped arriving, cannot be checked today. Nothing counts or logs a +/// bare-form read-back, so there is no observed legacy-reader traffic to look +/// at, and the elapsed time alone cannot tell anyone whether a deployment +/// somewhere is still serving them. Scheduling the removal needs that signal +/// to exist first. Until it does the reader stays, and keeping it costs one +/// string comparison per read-back. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == HMAC_PROVIDER_KEY && provider.accepts_id(value), + } +} + +/// The full created identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +/// The providers whose identifiers a partner or diagnostic path accepts. +/// +/// Pull sync, batch sync, and the admin lookup each take an identifier from +/// outside the organic request path and have to decide whether Trusted Server +/// issued it. The answer is in two parts. The **global cookie bounds** (the +/// length cap and the cookie-safe alphabet, see `ec_id_has_only_allowed_chars`) +/// apply to every identifier whichever provider created it. The rest is +/// **dispatched by the `{code}~` prefix** to the provider that owns that code, +/// which canonicalizes its own value part and decides whether the canonical +/// form is one of its own. A code no provider in the set owns is rejected, so a +/// second provider's identifiers can never be adopted or written under this +/// deployment's keys. +/// +/// Batch sync keys its rows through [`canonical_kv_key`](Self::canonical_kv_key) +/// here. Pull sync and the admin lookup still read and write rows by the raw +/// active identifier rather than the canonical form, so for a provider whose +/// canonical form differs from the cookie value they can key the wrong row. +/// That gap is recorded on `EcContext::kv_key_for` and tracked as a known +/// issue for a later change. +/// +/// The set holds the deployment's active provider. The design's +/// `legacy_providers` reader list, the providers that never create but must still +/// recognize identifiers a previous provider issued, is not implemented on this +/// branch, so [`active`](Self::active) fills `readers` with the one active +/// provider. That is the seam: when the configured legacy readers land they are +/// built alongside the active provider and pushed into the same list, and +/// neither [`accepts`](Self::accepts) nor +/// [`canonical_kv_key`](Self::canonical_kv_key) changes. +pub struct AcceptedProviders<'a> { + readers: Vec<&'a dyn EdgeCookieProvider>, +} + +impl<'a> AcceptedProviders<'a> { + /// The set holding only the deployment's active provider. + /// + /// `None` means no provider is selected, so the deployment is stateless. + #[must_use] + pub fn active(provider: Option<&'a dyn EdgeCookieProvider>) -> Self { + Self { + readers: provider.into_iter().collect(), + } + } + + /// The provider in the set that owns `full`'s code. + /// + /// Dispatch is on the code alone, before any provider looks at a value, so + /// an identifier a partner echoed back in a different case still reaches + /// its own provider to be canonicalized rather than being rejected first. + /// A legacy bare identifier predates the envelope and belongs to the + /// built-in HMAC provider alone. + fn owner(&self, full: &str) -> Option<&'a dyn EdgeCookieProvider> { + let (code, _) = split_provider_code(full); + self.readers.iter().copied().find(|provider| match code { + Some(code) => provider.code().as_str() == code, + None => provider.id() == HMAC_PROVIDER_KEY, + }) + } + + /// Whether `full` is an identifier this deployment accepts. + #[must_use] + pub fn accepts(&self, full: &str) -> bool { + self.canonical_kv_key(full).is_some() + } + + /// The identity-graph key for `full`, or `None` when nothing in the set + /// accepts it. + /// + /// The owning provider supplies the canonical form of its own value part + /// and the code prefix is preserved verbatim, so two providers' rows can + /// never share a key. + #[must_use] + pub fn canonical_kv_key(&self, full: &str) -> Option { + if !ec_id_has_only_allowed_chars(full) { + return None; + } + match self.owner(full) { + Some(owner) => { + let key = provider_kv_key(owner, full); + provider_owns_id(owner, &key).then_some(key) + } + // No provider is selected, so there is no code to dispatch on and + // the built-in HMAC grammar is the fallback, the same fallback + // `EcContext::accepts_id` has always used for a stateless + // deployment. + None if self.readers.is_empty() => { + let key = generation::normalize_ec_id_for_kv(full); + generation::is_valid_ec_id(&key).then_some(key) + } + // A code that belongs to some other deployment's provider. + None => None, + } + } +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration and come in two types, which +/// reach the same outcome (a `ts-ec` cookie) by different routes: +/// +/// - **Server-side** (for example [`HmacProvider`]): derives the identifier at +/// the edge in [`generate`](Self::generate), and the page response sets the +/// cookie. Nothing client-side is involved. +/// +/// A provider that cannot derive an identifier at the edge returns a +/// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so +/// the request proceeds without an Edge Cookie rather than failing. +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it creates. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// the provider-code registry before it can exist, so no two providers + /// can ever create colliding identifiers. Core applies the code at + /// creation and checks it at read-back, and the provider itself only ever + /// sees its own value part. + fn code(&self) -> ProviderCode; + + /// Whether this provider was built from evidence about one request. + /// + /// Almost every provider is built from configuration and services that are + /// the same for every request, so one instance can be resolved once and + /// handed to all of them. [`HostSignalProvider`] is the exception, because + /// it is built from the TLS and HTTP/2 signals of a single request and + /// answers `true` here. A composition root reads this through + /// [`build_reusable_provider`] to decide whether keeping the instance is + /// safe, and keeping a request-scoped one would serve every later request + /// from the first request's evidence. + /// + /// The default is `false`, which is right for a provider whose constructor + /// takes only configuration and long-lived services. + fn is_request_scoped(&self) -> bool { + false + } + + /// Derives an Edge Cookie identifier from the request evidence in + /// `request_info` and the gating context in `input`. + /// + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`], the + /// one provider core builds in. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } + + /// The permissions this provider's data use requires. + /// + /// Trusted Server executes the provider only when every permission returned + /// here is set. The default is empty, so a vendor-neutral provider requires + /// no permission. A provider that stores identity on the device, or shares it + /// onward, declares the matching permission so the request's country and + /// signal rules can gate it. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +/// +/// The client IP is this provider's only input, so it is this provider that +/// requires one. On a host that cannot supply one, [`RequestInfo::client_ip`] +/// is the empty string and [`generate`](Self::generate) fails rather than +/// hashing the empty string into an identifier every visitor on that host +/// would share. The failure is returned to the caller. The publisher proxy and +/// integration proxy log it and serve the response without an Edge Cookie. A +/// provider that reads other evidence makes its own decision and is unaffected. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + HMAC_PROVIDER_CODE + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let client_ip = request_info.client_ip(); + if client_ip.is_empty() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` requires the client IP, and this host \ + could not supply one" + .to_owned(), + })); + } + let id = generation::generate_ec_id(self.passphrase.expose(), client_ip)?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // The HMAC provider writes the Edge Cookie to the device, so it requires + // permission to store on the device (TCF Purpose 1). Whether that needs a + // signal is decided by the country rules, not by the provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// The built-in host-signal Edge Cookie provider. +/// +/// Derives the identifier from the host signals (TLS JA4 and HTTP/2, read +/// from the injected [`HostSignals`]) plus the client IP (from [`RequestInfo`]), +/// keyed by the configured passphrase. It is host-agnostic: it depends on the +/// `HostSignals` capability, so any host that supplies one can use it. A host +/// that supplies no `HostSignals` cannot build it, and the request stops. +#[derive(Debug, Clone)] +pub struct HostSignalProvider { + passphrase: Redacted, + host_signals: Arc, +} + +impl HostSignalProvider { + /// Creates the provider with the passphrase and its injected host signals. + #[must_use] + pub fn new(passphrase: Redacted, host_signals: Arc) -> Self { + Self { + passphrase, + host_signals, + } + } +} + +impl EdgeCookieProvider for HostSignalProvider { + fn id(&self) -> &'static str { + HOST_SIGNALS_PROVIDER_KEY + } + + // Built from the signals of one request, so it is only ever valid for + // that request and must never be kept and reused. + fn is_request_scoped(&self) -> bool { + true + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("hs00") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let ja4 = self.host_signals.ja4().unwrap_or_default(); + let h2 = self.host_signals.h2().unwrap_or_default(); + // With no signal at all, creating an identifier would silently degrade + // to an IP-only identifier under the host-signals name. Defer instead, + // meaning no identity this request, and the request proceeds. + if ja4.is_empty() && h2.is_empty() { + log::warn!("Host-signal EC provider found no TLS/HTTP-2 signals; deferring"); + return Ok(GeneratedEdgeCookie::default()); + } + let id = generation::generate_hmac_ec_id( + self.passphrase.expose(), + &[ja4, h2, request_info.client_ip()], + )?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // Writes the Edge Cookie to the device, so it requires necessary.operations.storage + // (TCF Purpose 1), the same gate as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// Refuses an injected provider that claims a name core supplies itself. +/// +/// Two suppliers cannot own one name. Core ships the `hmac` provider, and once +/// this work merges IAB Tech Lab is itself a vendor shipping an HMAC provider, +/// so the two really can arrive under the same name in one deployment. The +/// resolution order alone would answer that by quietly preferring the built-in +/// one and dropping the injected provider, which an operator has no way to see, +/// so the pair is refused here and the error names both claimants. +/// +/// The check runs whatever the selector says, so an operator is told at startup +/// rather than on the first request that happens to select the contested name, +/// and it runs before the selection is read so a deployment cannot hide the +/// clash by selecting something else. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the injected provider's id +/// is one of [`BUILTIN_PROVIDER_KEYS`]. +fn ensure_no_name_collision( + injected: Option<&dyn EdgeCookieProvider>, +) -> Result<(), Report> { + let Some(injected) = injected else { + return Ok(()); + }; + let Some(claimed) = BUILTIN_PROVIDER_KEYS + .iter() + .find(|key| **key == injected.id()) + else { + return Ok(()); + }; + Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider name `{claimed}` is claimed twice, by the provider \ + built into Trusted Server core and by the provider this deployment's \ + adapter injects. Give the injected provider a name of its own and select \ + it under that name, because `[ec] provider = \"{claimed}\"` cannot mean \ + both of them." + ), + })) +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector. +/// +/// This is the composition root for the built-in providers: the adapter supplies +/// the [`HostSignals`] when the host can produce them, and this constructs the +/// selected provider. The per-request [`RequestInfo`] is passed borrowed to +/// [`generate`](EdgeCookieProvider::generate) at call time rather than stored, so +/// no request snapshot is cloned here. Returns `Ok(None)` when no provider is +/// selected, so the caller stays stateless. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the named provider cannot be +/// built: a built-in name whose configuration block is missing, a built-in name +/// whose host capability this host does not supply, or a name this deployment's +/// adapter does not inject. All fail loudly rather than leaving the deployment +/// running stateless under a selector that says otherwise. +pub fn build_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + ensure_no_name_collision(injected.as_deref())?; + let Some(selection) = ec.provider.as_ref() else { + return Ok(None); + }; + let provider: Option> = match selection { + // Explicit statelessness: the same meaning as omitting the selector. + EcProviderSelection::None => None, + // Every provider is named, and this is the one place a name is resolved + // to an implementation. Nothing else in the codebase asks whether a + // name is built in. + EcProviderSelection::Named(key) => { + Some(resolve_named_provider(key, ec, host_signals, injected)?) + } + }; + Ok(provider) +} + +/// Resolves one provider name to its implementation. +/// +/// A name is looked for among the providers built into core first, and is +/// otherwise the name of a provider the adapter injects through +/// [`RuntimeServices`](crate::platform::RuntimeServices), the same seam the +/// device and geo providers use, so core never names a vendor. The injected +/// provider is used when its own id matches the name, and its +/// `[ec.providers.]` block is read by the adapter that built it. +/// +/// Looking at core first is safe only because +/// [`ensure_no_name_collision`] has already refused an injected provider that +/// claims a built-in name, so this order can never shadow one silently. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the name matches no provider +/// this deployment can build, when a built-in name has no configuration block, +/// or when a built-in name needs a host capability this host does not supply. +/// All fail loudly rather than silently running stateless. +fn resolve_named_provider( + key: &str, + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result, Report> { + // The only place that knows a provider is built into core rather than + // supplied as a module. Each arm disappears, along with its name constant, + // when that provider becomes a module like every other provider, after + // which its name resolves through the injected path below and nothing else + // changes. + // + // Settings validation rejects a built-in name with no block before this + // runs, so reaching the error means the two checks have drifted apart. + // Stopping is the only safe answer: returning no provider would run the + // deployment stateless under a selector that says it has an identity + // provider. + if key == HMAC_PROVIDER_KEY { + let config = ec.providers.hmac.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` is selected but has no \ + `[ec.providers.hmac]` configuration" + .to_owned(), + }) + })?; + return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); + } + + // The host-signal provider needs signals only some hosts supply, and + // that check cannot be made in settings validation at all, so it is made + // here rather than creating a degraded identifier under this name. + if key == HOST_SIGNALS_PROVIDER_KEY { + let config = ec.providers.host_signals.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `host-signals` is selected but has no \ + `[ec.providers.host-signals]` configuration" + .to_owned(), + }) + })?; + let signals = host_signals.ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "The host-signals Edge Cookie provider requires a host that supplies \ + TLS/HTTP-2 signals, which this host does not" + .to_owned(), + }) + })?; + return Ok(Box::new(HostSignalProvider::new( + config.passphrase.clone(), + signals, + ))); + } + + injected + .filter(|provider| provider.id() == key) + .map(|provider| Box::new(SharedProvider(provider)) as Box) + .ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{key}` is selected but this deployment's \ + adapter does not provide it" + ), + }) + }) +} + +/// Checks once, at startup, that this deployment can build the provider named +/// by the `[ec] provider` selector. +/// +/// The composition root calls this while it builds application state, passing +/// the same services it will put into +/// [`RuntimeServices`](crate::platform::RuntimeServices) on every request. +/// [`build_provider`] reads no request data, so the answer is the same for +/// every request and a selection the adapter can never supply fails at startup +/// rather than on the first request. A stateless deployment (no selector, or +/// `"none"`) passes. +/// +/// `host_signals` answers whether this adapter supplies a [`HostSignals`] +/// service at all, which is fixed per deployment, rather than what any one +/// request's signals are. An adapter that injects host signals on every +/// request passes an instance here even though its values are empty at +/// startup, and an adapter that never injects them passes `None`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built from the services this deployment injects. +pub fn ensure_provider_available( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result<(), Report> { + build_shared_provider(ec, host_signals, injected)?; + Ok(()) +} + +/// Resolves the selected provider into a shared handle. +/// +/// The same resolution as [`build_provider`], returned as an `Arc` rather than +/// a `Box` so one instance can be held in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and read by every +/// request. Use [`build_reusable_provider`] at a composition root, which adds +/// the one check that decides whether keeping the instance is safe. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_shared_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + Ok(build_provider(ec, host_signals, injected)?.map(Arc::from)) +} + +/// The provider a composition root may keep and hand to every request, when +/// the selection is one that can be kept at all. +/// +/// Resolving is also the startup check, so a selection this deployment cannot +/// satisfy fails here rather than on the first request, exactly as +/// [`ensure_provider_available`] makes it fail. What this adds is the answer to +/// a second question, which is whether the provider that came back is the same +/// for every request. Most are, because they are built from configuration +/// alone, and keeping one saves resolving the same settings again on every +/// request. +/// +/// [`HostSignalProvider`] is not, because it is built from the signals of +/// one request and reports +/// [`is_request_scoped`](EdgeCookieProvider::is_request_scoped). Keeping that +/// one would freeze the signals captured while application state was built, +/// which on every adapter here are empty, so every later request would find no +/// signals and defer. `Ok(None)` comes back for it, the adapter threads +/// nothing, and the request path resolves it per request against that request's +/// own signals. +/// +/// `Ok(None)` therefore means "nothing to keep", which covers both a stateless +/// deployment and a provider that must be resolved per request. Both leave the +/// request path resolving for itself, which is what it did before anything was +/// kept. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_reusable_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + let Some(provider) = build_shared_provider(ec, host_signals, injected)? else { + return Ok(None); + }; + if provider.is_request_scoped() { + log::debug!( + "Edge Cookie provider `{}` is built from request evidence, so it is resolved per request rather than kept", + provider.id(), + ); + return Ok(None); + } + Ok(Some(provider)) +} + +/// The Edge Cookie provider to use for this request. +/// +/// A provider reaches the request path through one seam only. An adapter +/// resolves `[ec] provider` once while it builds application state and threads +/// the answer into +/// [`RuntimeServices::resolved_ec_provider`](crate::platform::RuntimeServices::resolved_ec_provider), +/// and that same instance comes back here with nothing resolved or constructed +/// again on the request path. When nothing was threaded, this builds from +/// `[ec]` settings alone, which is what a deployment selecting only a built-in +/// provider does. +/// +/// # Errors +/// +/// The same errors as [`build_provider`], and only when nothing was threaded, +/// because a threaded provider has already been resolved successfully. +pub fn request_provider( + ec: &Ec, + services: &crate::platform::RuntimeServices, +) -> Result>, Report> { + if let Some(resolved) = services.resolved_ec_provider() { + return Ok(Some(resolved)); + } + build_shared_provider(ec, services.host_signals(), None) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn is_request_scoped(&self) -> bool { + self.0.is_request_scoped() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } + + fn required_permissions(&self) -> PermissionSet { + self.0.required_permissions() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evidence::OwnedRequestInfo; + use crate::settings::{EcProviders, HmacProviderConfig, HostSignalsProviderConfig}; + use http::HeaderMap; + + #[test] + fn a_malformed_provider_code_is_refused_rather_than_panicking() { + // `ProviderCode::new` is public, so a vendor crate can reach it with a + // value it assembled rather than a literal. Every rejected shape has to + // come back as `None`, because a panic here would take down whatever + // request the caller was serving. + for malformed in ["", "abc", "abcde", "AB12", "t0a_", "t0a-", "t0a ", "t.ac"] { + assert_eq!( + ProviderCode::new(malformed), + None, + "`{malformed}` is outside the registry format and should be refused" + ); + } + + assert_eq!( + ProviderCode::new("t0ac").map(ProviderCode::as_str), + Some("t0ac"), + "a well-formed code should still be accepted" + ); + } + + #[test] + fn the_provider_code_macro_keeps_the_compile_time_guarantee() { + // The macro checks a literal while the crate is compiled and yields the + // code itself, so the codes written across this workspace stay as + // strong as the old panicking constructor made them, with none of the + // run-time risk. + assert_eq!( + crate::provider_code!("t0ac").as_str(), + "t0ac", + "the macro should yield the code it was given" + ); + assert_eq!( + HMAC_PROVIDER_CODE.as_str(), + HMAC_PROVIDER_KEY, + "the built-in code should still be the built-in key" + ); + } + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + fn header(name: &str, value: &str) -> (http::HeaderName, http::HeaderValue) { + ( + http::HeaderName::from_bytes(name.as_bytes()).expect("should parse header name"), + http::HeaderValue::from_str(value).expect("should parse header value"), + ) + } + + #[test] + fn reserved_response_effect_rejects_the_namespace_core_manages() { + for (name, value, expected) in [ + ( + "set-cookie", + "ts-ec=hmac~deadbeef.abc123; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ( + "Set-Cookie", + " TS-EIDS=x; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ("x-ts-ec", "spoofed", ReservedResponseEffect::ReservedHeader), + ( + "X-TS-partner.example.com", + "uid", + ReservedResponseEffect::ReservedHeader, + ), + ("content-length", "0", ReservedResponseEffect::FramingHeader), + ( + "Transfer-Encoding", + "chunked", + ReservedResponseEffect::FramingHeader, + ), + ("connection", "close", ReservedResponseEffect::FramingHeader), + ( + "cache-control", + "public, max-age=31536000", + ReservedResponseEffect::FramingHeader, + ), + ( + "Cache-Control", + "public", + ReservedResponseEffect::FramingHeader, + ), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + Some(expected), + "`{name}` should be reserved" + ); + } + } + + #[test] + fn reserved_response_effect_allows_provider_owned_effects() { + for (name, value) in [ + ("set-cookie", "acme-evidence=abc; Path=/; Secure"), + ("set-cookie", "sharedId=abc"), + ("accept-ch", "Sec-CH-UA-Full-Version-List"), + ("x-acme-probe", "1"), + ("vary", "Sec-CH-UA"), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + None, + "`{name}` is the provider's own and should be allowed" + ); + } + } + + #[test] + fn reserved_response_effect_reads_a_non_utf8_set_cookie_as_bytes() { + // A `Set-Cookie` carrying a byte above 127 cannot be read as a string, + // so the cookie name is matched on raw bytes. Reading it as UTF-8 and + // giving up on failure would let this value through. + let name = http::header::SET_COOKIE; + let mut bytes = b"ts-ec=value".to_vec(); + bytes.push(0xff); + bytes.extend_from_slice(b"; Path=/"); + let value = + http::HeaderValue::from_bytes(&bytes).expect("should build a non-utf8 header value"); + assert!( + value.to_str().is_err(), + "the test value should not be readable as UTF-8" + ); + assert_eq!( + reserved_response_effect(&name, &value), + Some(ReservedResponseEffect::ManagedCookie), + "a non-UTF-8 Set-Cookie should still be matched on its cookie name" + ); + } + + /// A stand-in for a vendor provider an adapter injects. + #[derive(Debug)] + struct VendorProvider; + + impl EdgeCookieProvider for VendorProvider { + fn id(&self) -> &'static str { + "acme" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ac") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn accepted_providers_splits_global_bounds_from_provider_dispatch() { + let hmac = HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())); + let hmac_value = format!("{}.ABC123", "a".repeat(64)); + let active = AcceptedProviders::active(Some(&hmac)); + + // The global bounds come first and apply whoever created the value. A + // character outside the cookie-safe alphabet, or a value over the + // length cap, never reaches a provider. + assert!( + !active.accepts(&format!("hmac~{hmac_value} with spaces")), + "the cookie-safe alphabet is a global bound" + ); + assert!( + !active.accepts(&format!("hmac~{}", "a".repeat(300))), + "the length cap is a global bound" + ); + + // Then dispatch by code to the provider that owns it. + assert!( + active.accepts(&format!("hmac~{hmac_value}")), + "the active provider's own code is accepted" + ); + assert!( + active.accepts(&hmac_value), + "the legacy bare form belongs to the built-in provider" + ); + assert!( + !active.accepts(&format!("t0ac~{hmac_value}")), + "a code no configured provider reads is rejected even in the HMAC shape" + ); + + // A vendor provider's own identifiers are accepted when it is the + // active one, and the built-in bare form then belongs to nobody. + let vendor = AcceptedProviders::active(Some(&VendorProvider)); + assert!( + vendor.accepts(&format!("t0ac~{hmac_value}")), + "the vendor provider's code is accepted when it is active" + ); + assert!( + !vendor.accepts(&hmac_value), + "the legacy bare form is the built-in provider's alone" + ); + + // With no provider selected the deployment is stateless, so the + // built-in grammar is the fallback, as it has always been. + let stateless = AcceptedProviders::active(None); + assert!( + stateless.accepts(&hmac_value), + "a stateless deployment falls back to the built-in grammar" + ); + assert!( + !stateless.accepts("not-an-identifier"), + "the fallback is still the built-in grammar, not anything goes" + ); + } + + #[test] + fn the_selector_round_trips_through_serialization() { + // The typed selector must not change the configuration surface. The + // same TOML has to parse to the same choice, and serializing has to + // write the same key back, so an existing operator configuration keeps + // working and a config push does not rewrite the selector. + for (key, expected) in [ + (EcProviderSelection::NONE_KEY, EcProviderSelection::None), + ( + HMAC_PROVIDER_KEY, + EcProviderSelection::Named(HMAC_PROVIDER_KEY.to_owned()), + ), + ("acme", EcProviderSelection::Named("acme".to_owned())), + ] { + let ec: Ec = toml::from_str(&format!("provider = \"{key}\"")) + .expect("should parse the [ec] section"); + assert_eq!( + ec.provider.as_ref(), + Some(&expected), + "`{key}` should select the provider it names" + ); + assert_eq!( + expected.key(), + key, + "`{key}` should report itself under the key it was written as" + ); + + // The serialized form is the string itself, byte for byte, so an + // operator configuration written before the selector was typed + // parses and is written back identically. + let value = + toml::Value::try_from(expected.clone()).expect("should serialize the selection"); + assert_eq!( + value, + toml::Value::String(key.to_owned()), + "`{key}` should serialize to exactly its own string" + ); + + let written = toml::to_string(&ec).expect("should serialize the [ec] section"); + assert!( + written.contains(&format!("provider = \"{key}\"")), + "`{key}` should be written back unchanged, got: {written}" + ); + + // A full round trip through the document leaves the same choice. + let reparsed: Ec = toml::from_str(&written).expect("should reparse the [ec] section"); + assert_eq!( + reparsed.provider.as_ref(), + Some(&expected), + "`{key}` should survive a serialize and parse round trip" + ); + } + } + + #[test] + fn each_selection_builds_what_its_string_key_built_before() { + // `none` is stateless, exactly as omitting the selector is. + let none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + assert!( + build_provider(&none, None, None) + .expect("explicit statelessness should build") + .is_none(), + "`none` should select no provider" + ); + + // `hmac` with its block builds the built-in provider. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let built = build_provider(&hmac, None, None) + .expect("the hmac selection should build") + .expect("the hmac selection should yield a provider"); + assert_eq!( + built.id(), + HMAC_PROVIDER_KEY, + "`hmac` should select the built-in provider" + ); + assert_eq!( + built.code(), + HMAC_PROVIDER_CODE, + "the built-in provider should carry the built-in code" + ); + + // An arbitrary vendor key selects the provider the adapter injected + // under that same key. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let built = build_provider(&vendor, None, Some(Arc::new(VendorProvider))) + .expect("the vendor selection should build") + .expect("the vendor selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "a vendor key should select the injected provider of that id" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::permissions::PermissionMaps; + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned(), HeaderMap::new()) + } + + /// Test host signals with fixed JA4/H2 values. + #[derive(Debug)] + struct TestHostSignals { + ja4: Option, + h2: Option, + } + + impl HostSignals for TestHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0in") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + /// A vendor provider that claims the name core already uses for its + /// built-in HMAC provider. + #[derive(Debug)] + struct VendorNamedHmacProvider; + + impl EdgeCookieProvider for VendorNamedHmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0vh") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn two_providers_claiming_one_name_are_refused_and_both_are_named() { + // Once this work merges, IAB Tech Lab supplies an HMAC provider as a + // vendor module while core still supplies one of its own, so a + // deployment really can wire two providers called `hmac`. Resolution + // order alone would prefer the built-in one and drop the injected one + // with nothing said, which is the fault this guards. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let selected_hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = build_provider( + &selected_hmac, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("two providers claiming `hmac` should be refused"); + let message = err.to_string(); + assert!( + message.contains(HMAC_PROVIDER_KEY), + "the error should name the contested name, got: {message}" + ); + assert!( + message.contains("core") && message.contains("adapter"), + "the error should name both claimants, got: {message}" + ); + + // The clash is a wiring fault, not a property of the selection, so + // selecting something else does not hide it and the operator still + // learns at startup. + let selected_elsewhere = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + let err = ensure_provider_available( + &selected_elsewhere, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("the clash should be refused whatever the selector says"); + assert!( + err.to_string().contains(HMAC_PROVIDER_KEY), + "the startup check should name the contested name too, got: {err}" + ); + + // A vendor name of its own is unaffected. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + build_provider(&vendor, None, Some(Arc::new(VendorProvider))) + .expect("a vendor provider under its own name should still build"); + } + + #[test] + fn hmac_provider_requires_store_on_device() { + let provider = HmacProvider::new(test_passphrase()); + let required = provider.required_permissions(); + assert!( + required.contains(Permission::StoreOnDevice), + "the HMAC provider writes a cookie, so it requires necessary.operations.storage" + ); + assert!( + !required.contains(Permission::SelectPersonalisedAds), + "the HMAC provider requires no advertising permissions" + ); + } + + #[test] + fn host_signal_provider_mints_from_fingerprints_and_requires_store_on_device() { + let signals = Arc::new(TestHostSignals { + ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), + h2: Some("1:65536;4:6291456".to_owned()), + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_some(), + "the host-signal provider should create an identifier from the signals" + ); + assert!( + provider + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal provider writes a cookie, so it requires necessary.operations.storage" + ); + } + + /// A minimal provider that overrides nothing optional, used to prove the + /// trait defaults. + #[derive(Debug)] + struct MinimalProvider; + + impl EdgeCookieProvider for MinimalProvider { + fn id(&self) -> &'static str { + "minimal" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0mi") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn a_neutral_provider_requires_no_permissions_by_default() { + // MinimalProvider does not override required_permissions, so it + // inherits the trait default of none and requires no permission. + assert!( + MinimalProvider.required_permissions().is_empty(), + "a vendor-neutral provider requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacProvider::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the provider's required permission is not set, so Trusted + // Server would not commit the Edge Cookie. + assert!( + !maps.resolve(None, |_| false).all_set(required), + "the floor should not run the Edge Cookie provider without the permission set" + ); + + // A grant signal for necessary.operations.storage: the provider's permission is now set. + assert!( + maps.resolve(None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie provider runs once necessary.operations.storage is set" + ); + } + + #[test] + fn host_signal_provider_defers_without_signals() { + let signals = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "with no host signals the provider should defer rather than create an IP-only identifier" + ); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + + let err = build_provider(&ec, None, None) + .expect_err("selecting a provider the adapter does not inject should error"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + } + + #[test] + fn selecting_hmac_without_its_block_fails_loudly() { + // `Ec::validate_provider_selection` rejects this pair before settings + // reach the composition root, so the state is built directly here to + // reach the seam. If the two checks ever drift apart, `build_provider` + // must still stop rather than hand back a stateless deployment. + let ec = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + + let err = build_provider(&ec, None, None) + .expect_err("selecting hmac with no [ec.providers.hmac] block should error"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn a_provider_built_from_request_evidence_is_never_kept_and_reused() { + // The host-signal provider captures the signals of the request it + // was built for. A composition root builds application state with an + // empty host-signal service, because there is no request yet, so + // keeping that instance would serve every later request from empty + // signals and the provider would defer forever. It must come back + // as nothing to keep, leaving the request path to resolve it against + // the signals each request actually carried. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let host_signals_selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let startup_signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + + // It resolves, so the startup check still passes on a host that + // supplies the service. + let resolved = build_shared_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None, + ) + .expect("the host-signal selection should resolve on a host that supplies signals") + .expect("the selection should yield a provider"); + assert!( + resolved.is_request_scoped(), + "the host-signal provider should declare itself built from request evidence" + ); + + // It is not offered for reuse. + assert!( + build_reusable_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None + ) + .expect("the host-signal selection should still pass the startup check") + .is_none(), + "a provider built from request evidence must never be kept for later requests" + ); + + // A provider built from configuration alone is still kept, so the + // saving stands for every selection that can take it. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac_selected = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let kept = build_reusable_provider(&hmac_selected, None, None) + .expect("the hmac selection should resolve") + .expect("a provider built from configuration alone should be kept"); + assert_eq!( + kept.id(), + HMAC_PROVIDER_KEY, + "the kept provider should be the selected one" + ); + } + + #[test] + fn the_request_path_reuses_the_provider_the_composition_root_resolved() { + // A composition root resolves the selection once while it builds + // application state, which is the same work `build_provider` does on a + // request, so doing both means doing it twice for every request. The + // resolved provider is threaded into `RuntimeServices`, and this is the + // assertion that the request path takes it rather than resolving again: + // the same allocation, not merely an equal one. + let ec = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let resolved = build_reusable_provider(&ec, None, Some(Arc::new(VendorProvider))) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let for_request = request_provider(&ec, &services) + .expect("the request path should take the resolved provider") + .expect("the resolved provider should be there"); + + assert!( + Arc::ptr_eq(&resolved, &for_request), + "the request path should reuse the resolved provider, not build a second one" + ); + + // An adapter that threads nothing still resolves for itself, so core + // driven directly behaves exactly as it did before. + let unthreaded = + crate::platform::test_support::noop_services_with_ec_provider(Arc::new(VendorProvider)); + let built = request_provider(&ec, &unthreaded) + .expect("an unthreaded adapter should resolve on the request path") + .expect("the selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "resolving on the request path should still select the injected provider" + ); + } + + #[test] + fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { + // A selection the adapter cannot supply is knowable without a request, + // so the composition root rejects it while application state is built. + let selected = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + let err = ensure_provider_available(&selected, None, None) + .expect_err("an uninjected provider should fail the startup check"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + + // Statelessness is a supported deployment, spelled either way, and must + // never be turned into a startup error. + ensure_provider_available(&Ec::default(), None, None) + .expect("should allow a deployment that selects no provider"); + let explicit_none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + ensure_provider_available(&explicit_none, None, None) + .expect("should allow the explicit `none` selection"); + } + + #[test] + fn the_startup_check_rejects_host_signals_on_a_host_that_supplies_none() { + // Whether the adapter injects a host-signal service is fixed per + // deployment, so selecting the host-signal provider on an adapter that + // injects none is knowable without a request. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = ensure_provider_available(&selected, None, None).expect_err( + "the host-signal provider should fail the startup check with no host signals", + ); + assert!( + err.to_string().contains("TLS/HTTP-2 signals"), + "the error should say the host supplies no signals, got: {err}" + ); + + let signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + ensure_provider_available(&selected, Some(signals), None).expect( + "should pass on a host that injects host signals, whatever this request's signals are", + ); + } +} diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..5b2c683b4 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -19,7 +19,7 @@ use crate::platform::{ }; use crate::settings::Settings; -use super::generation::{ec_hash, is_valid_ec_id}; +use super::generation::ec_hash; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; use super::rate_limiter::RateLimiter; @@ -55,16 +55,26 @@ struct PullSyncResponse { /// Builds post-send pull-sync context from the route EC context. /// -/// Returns `None` when consent denies EC or there is no active EC ID. +/// Returns `None` when sharing is not permitted or there is no active EC ID. +/// Pull sync sends the identifier to a partner, so it needs the same +/// permission pair as bidstream EIDs (storage plus personalised-ad +/// selection), not only the provider's storage permission. #[must_use] pub fn build_pull_sync_context(ec_context: &EcContext) -> Option { - if !ec_context.ec_allowed() { + if !ec_context.ec_sharing_allowed() { return None; } + // Accept an identifier from whichever provider this deployment reads, + // dispatched by the identifier's provider code, rather than only the + // built-in HMAC shape. A host-signal or vendor provider's identifiers are + // valid here for the same reason they are valid in the organic path. let ec_id_ref = ec_context.ec_value()?; - if !is_valid_ec_id(ec_id_ref) { - log::debug!("Pull sync: skipping dispatch because active EC ID is invalid format"); + if !ec_context.accepts_id(ec_id_ref) { + log::debug!( + "Pull sync: skipping dispatch because the active EC ID is not one this \ + deployment's providers accept" + ); return None; } @@ -499,6 +509,86 @@ mod tests { ); } + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn build_pull_sync_context_accepts_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one must + // still dispatch pull sync for the identifiers that provider created. + // The built-in grammar rejected every non-`hmac` code, so these + // identifiers worked in the organic path and were silently skipped + // here. + const OPAQUE_ID: &str = "t0op~Opaque_Value_MixedCase"; + + let mut settings = crate::test_support::tests::create_test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from("opaque")); + let services = crate::platform::test_support::noop_services_with_ec_provider( + std::sync::Arc::new(OpaqueProvider), + ); + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header("cookie", format!("ts-ec={OPAQUE_ID}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let geo = crate::geo::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + + let ec_context = + EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec_context.ec_value(), + Some(OPAQUE_ID), + "the opaque identifier should read back before pull sync sees it" + ); + + let context = build_pull_sync_context(&ec_context) + .expect("should dispatch pull sync for the active provider's identifier"); + assert_eq!( + context.ec_id(), + OPAQUE_ID, + "should carry the identifier through unchanged" + ); + } + #[test] fn build_pull_sync_context_rejects_invalid_ec_id() { let consent = ConsentContext { @@ -711,4 +801,23 @@ mod tests { "hour 1 rotation should move beta to front" ); } + + #[test] + fn build_pull_sync_context_accepts_a_minted_coded_ec_id() { + let consent = ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + // The form the creation path produces since the provider-code envelope. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + + let context = build_pull_sync_context(&ec_context) + .expect("should build pull sync context for a coded HMAC identifier"); + assert_eq!( + context.ec_id(), + ec_id, + "should dispatch the coded identifier as created" + ); + } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..972f9ada5 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,24 +11,28 @@ use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::IdentityInput; +use crate::ec::provider::{provider_owns_id, request_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; use crate::platform::RuntimeServices; -#[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and creates no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. @@ -36,32 +40,67 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = request_provider(&settings.ec, services)? else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (the client IP and the request headers) + // borrowed at call time, so nothing is cloned. A provider that also reads + // host signals takes them from the injected `HostSignals` service rather + // than from this request info. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path applies the permission gate at the call site, and the + // built-in provider reads neither the resolved permissions nor consent, so + // they are not threaded here. + let generated = provider.generate(&request_info, &IdentityInput::default())?; + let generated = crate::ec::provider::GeneratedEdgeCookie { + id: generated + .id + .map(|value| crate::ec::provider::apply_provider_code(provider.as_ref(), &value)), + response_headers: generated.response_headers, + }; + Ok(generated.id) } -/// Gets an existing EC ID from the request. +/// Reads whatever the request offers as an Edge Cookie identifier, before any +/// check that this deployment could have issued it. /// -/// Attempts to retrieve an existing EC ID from: -/// 1. The `x-ts-ec` header -/// 2. The `ts-ec` cookie +/// Reads the `x-ts-ec` header first and then the `ts-ec` cookie. Both are +/// client-controlled. `x-ts-ec` is stripped from responses but is not stripped +/// from inbound requests, so a caller must treat the result as an attacker's +/// choice of string. +/// +/// The only checks applied here are the global cookie bounds, the length cap +/// and the cookie-safe alphabet in +/// [`ec_id_has_only_allowed_chars`](crate::ec::cookies::ec_id_has_only_allowed_chars), +/// which every identifier must satisfy whichever provider created it. Those +/// bounds are a backstop on what may travel in a cookie, not a test of +/// authenticity, and on their own they accept any run of `[A-Za-z0-9._~-]`. /// -/// Returns `None` if neither source contains an EC ID. +/// Deciding whether this deployment issued the value needs the selected +/// provider, which this function does not have, so it is deliberately not +/// public. Use [`recognized_ec_id`], which applies provider ownership on top. /// /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { +pub(crate) fn unvalidated_ec_id_from_request( + req: &Request, +) -> Result, Report> { if let Some(ec_id) = req .headers() .get(HEADER_X_TS_EC) @@ -93,13 +132,77 @@ pub fn get_ec_id(req: &Request) -> Result, Report, +) -> Result, Report> { + let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { + return Ok(None); + }; + + let Some(provider) = request_provider(&settings.ec, services)? else { + log::debug!( + "No Edge Cookie provider configured; withholding the request's EC ID from egress" + ); + return Ok(None); + }; + + if provider_owns_id(provider.as_ref(), &ec_id) { + return Ok(Some(ec_id)); + } + + log::debug!( + "Withholding an EC ID provider `{}` does not recognize from egress", + provider.id(), + ); + Ok(None) +} + /// Gets or creates an EC ID from the request. /// /// Attempts to retrieve an existing EC ID from: /// 1. The `x-ts-ec` header /// 2. The `ts-ec` cookie /// -/// If neither exists, generates a new EC ID. +/// If neither exists, generates a new EC ID via the configured provider. +/// +/// Returns `Ok(None)` when no existing EC ID is present and no Edge Cookie +/// provider is configured, so the caller proceeds statelessly. /// /// # Errors /// @@ -109,14 +212,16 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { - if let Some(id) = get_ec_id(req)? { - return Ok(id); +) -> Result, Report> { + if let Some(id) = unvalidated_ec_id_from_request(req)? { + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -130,7 +235,7 @@ pub fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { +) -> Result, Report> { get_or_generate_ec_id_from_http_request(settings, services, req) } @@ -141,6 +246,7 @@ mod tests { use http::{HeaderName, header}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; @@ -155,13 +261,24 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID via edge_cookie"); - let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); + let bare_here = id_here + .strip_prefix("hmac~") + .expect("should carry the hmac provider code"); assert_eq!( - crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(bare_here), crate::ec::ec_hash(&id_canonical), "should produce the same identity hash prefix as the canonical generator" ); @@ -178,6 +295,10 @@ mod tests { } fn is_ec_id_format(value: &str) -> bool { + // The coded envelope: hmac~<64hex>.<6alnum>. + let Some(value) = value.strip_prefix("hmac~") else { + return false; + }; let mut parts = value.split('.'); let hmac_part = match parts.next() { Some(part) => part, @@ -206,11 +327,27 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is created" ); } @@ -219,10 +356,12 @@ mod tests { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID with client IP"); - let id_without_ip = generate_ec_id(&settings, &noop_services()) - .expect("should generate EC ID without client IP"); + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; @@ -235,22 +374,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a freshly created identifier always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -269,16 +414,69 @@ mod tests { ); } + #[test] + fn an_identifier_this_deployment_never_issued_is_not_recognized() { + // `x-ts-ec` is stripped from responses but not from inbound requests, + // so a client can put whatever it likes in it, and the raw reader + // prefers the header over the cookie. The global cookie bounds accept + // any run of `[A-Za-z0-9._~-]`, so they cannot tell an identifier this + // deployment created from one an attacker typed. Provider ownership is + // what draws that line. + let settings = create_test_settings(); + let services = noop_services(); + + for forged in [ + // Passes the alphabet and the length cap, owned by nobody. + "not-an-identifier", + // The built-in shape under another deployment's provider code. + "zz00~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234", + // This deployment's code carrying a value its provider never creates. + "hmac~not-the-hmac-shape", + ] { + let req = create_test_request(&[(HEADER_X_TS_EC, forged)]); + + // The raw reader hands it straight back, which is exactly why it is + // not the check anything may rely on. + assert_eq!( + unvalidated_ec_id_from_request(&req) + .expect("should read the header") + .as_deref(), + Some(forged), + "the global bounds alone should accept `{forged}`" + ); + + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring"), + None, + "`{forged}` was never issued here and must not be recognized" + ); + } + + // A value the selected provider does own is still recognized, so the + // check rejects forgeries rather than everything. + let issued = format!("hmac~{}.Ab1234", "a".repeat(64)); + let req = create_test_request(&[(HEADER_X_TS_EC, issued.as_str())]); + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring") + .as_deref(), + Some(issued.as_str()), + "an identifier the selected provider owns should still be recognized" + ); + } + #[test] fn test_get_ec_id_with_header() { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -290,11 +488,12 @@ mod tests { &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -307,7 +506,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); - let ec_id = get_ec_id(&req).expect("should get EC ID from http request"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should get EC ID from http request"); assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); } @@ -326,7 +526,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -334,7 +535,7 @@ mod tests { #[test] fn test_get_ec_id_none() { let req = create_test_request(&[]); - let ec_id = get_ec_id(&req).expect("should handle missing ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } @@ -344,7 +545,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -355,7 +557,8 @@ mod tests { (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); - let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should handle invalid header gracefully"); assert_eq!( ec_id, Some("valid_cookie_id".to_string()), @@ -369,7 +572,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" @@ -387,7 +591,8 @@ mod tests { &format!("{}=bad`. + /// Injected at `` open, ahead of [`Self::ad_slots_script`] and the + /// tsjs bundle, so page code can read the request's permission state before + /// anything runs. `None` under a shared-template mode, where the head is + /// cached and served to many readers and nothing request-scoped may appear + /// in it, so the seam carries the state there instead. + pub permissions_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -226,6 +234,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + permissions_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, @@ -254,6 +263,17 @@ impl HtmlProcessorConfig { self } + /// Attach the head script carrying this request's permission state. + /// + /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are + /// independent decisions: the permission state travels on every HTML + /// document the processor handles, whether or not the ad stack ran. + #[must_use] + pub fn with_permissions_script(mut self, permissions_script: Option) -> Self { + self.permissions_script = permissions_script; + self + } + /// Set what the `` seam injects. /// /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are @@ -372,6 +392,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); + let permissions_script = config.permissions_script.clone(); let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); @@ -404,10 +425,17 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let patterns = patterns.clone(); let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); + let permissions_script = permissions_script.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The permission state goes first, ahead of the slots and + // the bundle, because both of those and any vendor module + // may read it as soon as they run. + if let Some(ref state_script) = permissions_script { + snippet.push_str(state_script); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -829,6 +857,7 @@ mod tests { request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1805,6 +1834,7 @@ mod tests { r#""# .to_string(), ), + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1882,6 +1912,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1921,6 +1952,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1959,6 +1991,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2015,6 +2048,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2045,6 +2079,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2070,6 +2105,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2206,6 +2242,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 75de88afb..291d83410 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -886,6 +886,7 @@ mod tests { services, request, geo_info: None, + permissions: None, is_integration_route: false, })) }) @@ -926,6 +927,7 @@ mod tests { services, request: &mut request, geo_info, + permissions: None, is_integration_route: false, }, )); @@ -1113,6 +1115,7 @@ mod tests { services: &services, request: &mut request, geo_info: None, + permissions: None, is_integration_route: false, }, )) @@ -1724,6 +1727,7 @@ mod tests { services: &services, request: &mut request, geo_info: None, + permissions: None, is_integration_route: false, }, )); diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 162e9eb8c..0980a76c9 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1566,12 +1566,18 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1599,10 +1605,16 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d0cf37275..e60cbb973 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1841,8 +1841,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. + // EIDs resolved from the KV identity graph and gated on the + // resolved permission state in `handle_auction` via + // `gate_eids_by_permissions`. eids: request.user.eids.clone(), } .to_ext(), @@ -3057,7 +3058,13 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +assume_single_jurisdiction = true "#; /// Parse a TOML string containing only the `[integrations.prebid]` section diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..fb9a3dd91 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -329,6 +329,11 @@ pub struct RequestFilterInput<'a> { pub services: &'a RuntimeServices, pub request: &'a mut Request, pub geo_info: Option<&'a GeoInfo>, + /// The permission state resolved for this request at the start of the + /// request cycle, so a filter reads the same permissions the rest of the + /// request uses rather than resolving its own. `None` only on paths that + /// build no EC context, such as batch sync and admin diagnostics. + pub permissions: Option<&'a crate::permissions::PermissionState>, /// Whether the request matches a registered integration proxy route. pub is_integration_route: bool, } @@ -409,6 +414,10 @@ pub struct RequestFilterRegistryInput<'a> { pub services: &'a RuntimeServices, pub req: &'a mut Request, pub geo_info: Option<&'a GeoInfo>, + /// The permission state resolved for this request at the start of the + /// request cycle, passed on to every filter. `None` only on paths that + /// build no EC context, such as batch sync and admin diagnostics. + pub permissions: Option<&'a crate::permissions::PermissionState>, } /// Outcome returned by [`IntegrationRegistry::filter_request`]. @@ -911,6 +920,7 @@ impl IntegrationRegistry { services, req, geo_info, + permissions, } = input; let mut accumulated = RequestFilterEffects::default(); let is_integration_route = self.has_route(req.method(), req.uri().path()); @@ -922,6 +932,7 @@ impl IntegrationRegistry { services, request: req, geo_info, + permissions, is_integration_route, }) .await?; @@ -974,7 +985,7 @@ impl IntegrationRegistry { // may lack consent signals such as the Sec-GPC header. if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { - log::warn!("EC generation failed for integration proxy: {err:?}"); + log::error!("EC generation failed for integration proxy: {err:?}"); } } else { log::debug!( @@ -1347,6 +1358,7 @@ impl IntegrationRegistry { mod tests { use super::*; use crate::constants::COOKIE_TS_EC; + use crate::permissions::{Permission, PermissionSet, PermissionState}; use crate::platform::test_support::noop_services; use http::{HeaderValue, StatusCode, header}; @@ -1468,6 +1480,45 @@ mod tests { } } + /// Records the permission state each filter invocation received, so a test + /// can assert what the registry handed to the filter. + #[derive(Default)] + struct RecordingPermissionsFilter { + seen: std::sync::Mutex>>, + } + + impl RecordingPermissionsFilter { + /// The permission state observed by the last invocation, or `None` when + /// the filter has not run. + fn seen(&self) -> Option> { + *self + .seen + .lock() + .expect("should lock the recorded permission state") + } + } + + #[async_trait(?Send)] + impl IntegrationRequestFilter for RecordingPermissionsFilter { + fn integration_id(&self) -> &'static str { + "recording-permissions" + } + + async fn filter_request( + &self, + input: RequestFilterInput<'_>, + ) -> Result> { + *self + .seen + .lock() + .expect("should lock the recorded permission state") = + Some(input.permissions.copied()); + Ok(RequestFilterDecision::Continue( + RequestFilterEffects::default(), + )) + } + } + struct NoopHtmlPostProcessor; impl IntegrationHtmlPostProcessor for NoopHtmlPostProcessor { @@ -1613,6 +1664,7 @@ mod tests { services: &services, req: &mut req, geo_info: None, + permissions: None, })) .expect("should run request filter"); @@ -1639,6 +1691,55 @@ mod tests { } } + #[test] + fn filter_request_passes_the_resolved_permission_state_to_each_filter() { + let filter = Arc::new(RecordingPermissionsFilter::default()); + let registry = IntegrationRegistry::from_request_filters(vec![ + filter.clone() as Arc + ]); + let settings = crate::test_support::tests::create_test_settings(); + let services = crate::platform::test_support::noop_services(); + let permissions = + PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)); + let mut req = Request::builder() + .method(Method::GET) + .uri("https://example.com/page") + .body(EdgeBody::empty()) + .expect("should build request"); + + futures::executor::block_on(registry.filter_request(RequestFilterRegistryInput { + settings: &settings, + services: &services, + req: &mut req, + geo_info: None, + permissions: Some(&permissions), + })) + .expect("should run request filter"); + + assert_eq!( + filter.seen(), + Some(Some(permissions)), + "the filter should observe exactly the permission state resolved for the request" + ); + + // A path that builds no EC context passes no permissions, and the + // filter must see that absence rather than an empty state. + futures::executor::block_on(registry.filter_request(RequestFilterRegistryInput { + settings: &settings, + services: &services, + req: &mut req, + geo_info: None, + permissions: None, + })) + .expect("should run request filter"); + + assert_eq!( + filter.seen(), + Some(None), + "an absent permission state should reach the filter as `None`" + ); + } + #[test] fn test_exact_route_matching() { let routes = vec![( diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 80b2c4dfa..51c404a3d 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext, @@ -184,13 +184,17 @@ impl IntegrationProxy for TestlightIntegration { .await?; let req = http::Request::from_parts(parts, EdgeBody::empty()); - // Read EC ID from the ts-ec cookie forwarded by the client. - // The registry strips x-ts-ec before dispatching, so only the cookie is available here. - let ec_id = get_ec_id(&req) + // Read the EC ID from the ts-ec cookie forwarded by the client. The + // registry strips x-ts-ec before dispatching, so only the cookie is + // available here. The value goes into the proxied body as `user.id` and + // leaves the edge, so only one the selected provider recognizes is + // accepted, and a stateless deployment supplies none. + let ec_id = recognized_ec_id(settings, services, &req) .change_context(Self::error("Failed to read EC ID"))? .ok_or_else(|| { Report::new(Self::error( - "EC ID not found in ts-ec cookie — the client must carry a valid EC cookie", + "No EC ID this deployment's Edge Cookie provider recognizes was found \ + in the ts-ec cookie", )) })?; @@ -467,4 +471,92 @@ mod tests { ); }); } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn testlight_auction_request(ec_id: &str) -> http::Request { + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/testlight/auction") + .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) + .expect("should build request"); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC.clone(), + http::HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn testlight_integration() -> Arc { + TestlightIntegration::new(TestlightConfig { + enabled: true, + endpoint: "https://example.com/openrtb".to_string(), + timeout_ms: 1000, + shim_src: tsjs::tsjs_unified_script_src(), + rewrite_scripts: true, + }) + } + + #[test] + fn handle_refuses_to_egress_an_ec_id_the_provider_does_not_recognize() { + futures::executor::block_on(async { + // The identifier ends up in the proxied body as `user.id` and leaves + // the edge, so a value this deployment did not issue must stop here + // rather than be handed to the upstream endpoint. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + + let refused = testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(FOREIGN_CODED_EC_ID), + ) + .await + .expect_err("a foreign provider code should not be proxied upstream"); + drop(refused); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made with an unrecognized identifier" + ); + }); + } + + #[test] + fn handle_refuses_to_egress_any_ec_id_in_a_stateless_deployment() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + // The value is one the built-in provider would recognize, so only + // the absence of a selected provider can withhold it. + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + + let refused = testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(VALID_SYNTHETIC_ID), + ) + .await + .expect_err("a deployment that creates no identifier should proxy none"); + drop(refused); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made without a recognized identifier" + ); + }); + } } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..31bc119ae 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod evidence; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; @@ -55,6 +56,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod permissions; pub mod platform; pub mod price_bucket; pub mod proxy; diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs new file mode 100644 index 000000000..1c5dabc84 --- /dev/null +++ b/crates/trusted-server-core/src/permissions.rs @@ -0,0 +1,2288 @@ +//! Provider permissions: a technical permission model gating provider execution. +//! +//! A provider advertises the [`Permission`]s its data use *requires*. Trusted +//! Server resolves which permissions are currently *set* from the session's +//! signals and the country it resolves to, and refuses to run the Edge Cookie +//! provider when its required permissions are not set. The device and geo +//! providers declare their requirements through the same method, and the +//! built-in ones require none; gating their execution on that declaration is +//! follow-up work. +//! +//! The vocabulary is the IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +//! Europe purposes and used **only** as a technical identifier for a permission. +//! Two purposes have no Data Use yet: TCF purpose 1 (device storage) uses a +//! proposed `necessary.operations.storage` key and TCF purpose 11 keeps its TCF +//! identifier. No CMP or TCF *policy* is implemented here. Every TCF purpose +//! is resolved against the session's signals (a present TCF record grants or +//! revokes each purpose, and a US-style opt-out revokes), and the Data Uses +//! with no TCF purpose keep their configured baseline. +//! +//! How a permission is acquired varies by place, so the policy is written as a +//! tree of places: the top of the tree stands for the whole world, countries +//! sit under it keyed by ISO 3166-1 alpha-2 code, and a country's regions sit +//! under that country keyed by ISO 3166-2 subdivision code. +//! [`PermissionMaps::standard`] loads that tree from the embedded +//! `permissions.yaml` (see `DEFAULT_PERMISSION_RULES`). +//! +//! Resolution takes the most specific match and falls back to the node above: +//! the request's region when it is listed, otherwise its country, otherwise +//! the top of the tree. So a request with no country at all (no geo provider, +//! or a lookup that resolved nothing), and a request whose country has no rule, +//! both resolve to the top node's group. The top node also declares the +//! `jurisdiction` the consent gates use for a visitor whose place could not be +//! resolved (see [`PermissionMaps::default_jurisdiction`]). A geo provider that +//! reports an outright lookup failure is the exception, resolving every +//! permission to the requires-signal floor rather than the top node (see +//! [`PermissionMaps::floor_with`]), though no geo provider shipped today +//! reports one. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::Deserialize; +use serde_yaml_ng::Value; + +use crate::consent::jurisdiction::Jurisdiction; + +/// A technical permission a provider may require, labeled with its IAB Privacy +/// Taxonomy Data Use, or its IAB TCF Europe purpose where no Data Use exists yet. +/// +/// Only the identifier is used, with no TCF or taxonomy policy implemented. Every +/// named variant with a TCF purpose in `permissions.yaml` is resolved against +/// the session's signals. Only [`Permission::StoreOnDevice`] (and +/// [`Permission::SelectPersonalisedAds`] for sharing) gates a shipped provider +/// today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Permission { + /// TCF Purpose 1, store and/or access information on a device. It gates the + /// built-in Edge Cookie provider today. No IAB Privacy Taxonomy Data Use exists + /// for device storage yet, so this uses a proposed `necessary.operations` + /// key pending an upstream addition. + StoreOnDevice, + /// TCF Purpose 2, use limited data to select advertising. + SelectBasicAds, + /// TCF Purpose 3, create profiles for personalised advertising. + CreateAdsProfile, + /// TCF Purpose 4, use profiles to select personalised advertising. + SelectPersonalisedAds, + /// TCF Purpose 5, create profiles to personalise content. + CreateContentProfile, + /// TCF Purpose 6, use profiles to select personalised content. + SelectPersonalisedContent, + /// TCF Purpose 7, measure advertising performance. + MeasureAdPerformance, + /// TCF Purpose 8, measure content performance. + MeasureContentPerformance, + /// TCF Purpose 9, understand audiences through statistics. + MarketResearch, + /// TCF Purpose 10, develop and improve services. + DevelopServices, + /// TCF Purpose 11, use limited data to select content. No IAB Privacy + /// Taxonomy Data Use exists for limited-data content selection yet, so this + /// keeps its TCF identifier and is proposed upstream. Not gated today. + SelectBasicContent, + /// An IAB Privacy Taxonomy Data Use with no dedicated variant, identified by + /// its index into [`EXTRA_DATA_USES`]. These carry a policy flag in + /// `permissions.yaml` for completeness; no provider gates on them today. + Extra(u8), +} + +/// The Data Use identifiers for the named [`Permission`] variants, in variant +/// order (bit index 0..11). +const NAMED_DATA_USES: [&str; 11] = [ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual", + "advertising_marketing.profiling", + "advertising_marketing.first_party.targeted", + "advertising_marketing.personalize.profiling", + "advertising_marketing.personalize.content", + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.content_performance", + "analytics.ad_reporting.market_research", + "necessary.operations.improve", + "select-basic-content", +]; + +/// Every other IAB Privacy Taxonomy Data Use, carried so `permissions.yaml` can +/// set a policy flag for the whole taxonomy (bit index 11..). No provider gates +/// on these today; they exist for completeness, testing, and demonstration. +const EXTRA_DATA_USES: [&str; 53] = [ + "advertising_marketing", + "advertising_marketing.communications", + "advertising_marketing.communications.email", + "advertising_marketing.communications.sms", + "advertising_marketing.first_party", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + "advertising_marketing.personalize", + "advertising_marketing.personalize.system", + "advertising_marketing.serving", + "advertising_marketing.third_party", + "advertising_marketing.third_party.targeted", + "analytics", + "analytics.ad_reporting", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_fraud_detection", + "analytics.ad_reporting.ad_viewability", + "analytics.ad_reporting.campaign_insights", + "analytics.reporting", + "analytics.reporting.system", + "disclosure", + "disclosure.law_enforcement", + "disclosure.outside_counsel", + "disclosure.sale", + "disclosure.share", + "disclosure.third_party_sale", + "functional", + "functional.performance", + "functional.personalization", + "functional.security", + "necessary", + "necessary.employment", + "necessary.employment.hr", + "necessary.employment.hr.hiring", + "necessary.fraud_detection", + "necessary.legal_obligation", + "necessary.legal_obligation.age_verification", + "necessary.legal_obligation.content_moderation", + "necessary.legal_obligation.dsr", + "necessary.legal_obligation.hold", + "necessary.operations", + "necessary.operations.authentication", + "necessary.operations.debugging", + "necessary.operations.notifications", + "necessary.operations.notifications.email", + "necessary.operations.notifications.sms", + "necessary.operations.payment_processing", + "necessary.operations.quality_assurance", + "necessary.operations.security", + "necessary.operations.support", + "necessary.operations.survey", + "necessary.operations.upgrades", + "necessary.operations.website_use", +]; + +impl Permission { + /// The named permission variants, in bit-index order. + const NAMED: [Permission; 11] = [ + Permission::StoreOnDevice, + Permission::SelectBasicAds, + Permission::CreateAdsProfile, + Permission::SelectPersonalisedAds, + Permission::CreateContentProfile, + Permission::SelectPersonalisedContent, + Permission::MeasureAdPerformance, + Permission::MeasureContentPerformance, + Permission::MarketResearch, + Permission::DevelopServices, + Permission::SelectBasicContent, + ]; + + /// Every modeled permission: the named variants, then every other Privacy + /// Taxonomy Data Use. + pub fn all() -> impl Iterator { + Self::NAMED + .into_iter() + .chain((0..EXTRA_DATA_USES.len() as u8).map(Permission::Extra)) + } + + /// The Data Use identifier for this permission. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Permission::Extra(index) => EXTRA_DATA_USES[index as usize], + named => NAMED_DATA_USES[named.index() as usize], + } + } + + /// The stable bit position for this permission within a [`PermissionSet`]. + #[must_use] + const fn index(self) -> u8 { + match self { + Permission::StoreOnDevice => 0, + Permission::SelectBasicAds => 1, + Permission::CreateAdsProfile => 2, + Permission::SelectPersonalisedAds => 3, + Permission::CreateContentProfile => 4, + Permission::SelectPersonalisedContent => 5, + Permission::MeasureAdPerformance => 6, + Permission::MeasureContentPerformance => 7, + Permission::MarketResearch => 8, + Permission::DevelopServices => 9, + Permission::SelectBasicContent => 10, + Permission::Extra(index) => 11 + index, + } + } + + /// The single-bit mask for this permission within a [`PermissionSet`]. + const fn bit(self) -> u128 { + 1 << self.index() + } + + /// Returns the permission whose Data Use identifier matches `id` (for + /// example `"necessary.operations.storage"`), or `None` when it is unknown. + /// + /// Used to parse permission names from `permissions.yaml`. + #[must_use] + pub fn from_identifier(id: &str) -> Option { + Permission::all().find(|p| p.as_str() == id) + } +} + +impl core::fmt::Display for Permission { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A set of [`Permission`]s, stored as a bitset keyed by each permission's bit +/// index. +/// +/// Used both for what a provider requires and for what Trusted Server has set. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionSet(u128); + +impl PermissionSet { + /// The empty set, requiring or containing nothing. + #[must_use] + pub const fn none() -> Self { + Self(0) + } + + /// Returns this set with `permission` added. + #[must_use] + pub const fn with(self, permission: Permission) -> Self { + Self(self.0 | permission.bit()) + } + + /// Whether `permission` is in the set. + #[must_use] + pub const fn contains(self, permission: Permission) -> bool { + self.0 & permission.bit() != 0 + } + + /// Whether the set is empty. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every permission in `other` is also in this set. + #[must_use] + pub const fn contains_all(self, other: PermissionSet) -> bool { + self.0 & other.0 == other.0 + } + + /// Iterates the permissions in the set, in bit-index order. + /// + /// The built-ins read nothing from the full set; this serves a provider or + /// diagnostic path that enumerates what is present. + pub fn iter(self) -> impl Iterator { + Permission::all().filter(move |p| self.contains(*p)) + } +} + +impl FromIterator for PermissionSet { + fn from_iter>(iter: I) -> Self { + iter.into_iter() + .fold(PermissionSet::none(), PermissionSet::with) + } +} + +/// How a permission is acquired in a given country. +/// +/// This is intentionally country-keyed, not provider-keyed: a provider only +/// advertises *which* permissions it needs, and the country's rules decide *how* +/// each is obtained. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Acquisition { + /// Set without any signal, exempt or strictly necessary here. + Granted, + /// Set only when the incoming signals grant the matching TCF purpose. + /// The default, matching the floor an unresolved location falls to. + #[default] + RequiresSignal, + /// Never set in this country. + Denied, +} + +/// What a session signal says about a permission, layered on top of the +/// country/region baseline by the consent mapping. +/// +/// This module never reads consent directly. A caller maps its consent model (or +/// any other signal source) to a [`ConsentSignal`] per permission, and the +/// permission model applies it: a [`Grant`](Self::Grant) sets a +/// `RequiresSignal` permission, a [`Revoke`](Self::Revoke) drops a `Granted` one +/// (an opt-out), and [`Neutral`](Self::Neutral) leaves the baseline unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentSignal { + /// The signal grants this permission, so a `RequiresSignal` baseline is set. + Grant, + /// The signal withdraws this permission, dropping a `Granted` baseline. + Revoke, + /// The signal says nothing, so the baseline stands. + Neutral, +} + +/// The acquisition rule for each permission in one country or region. +/// +/// A `default` applies to any permission not explicitly overridden, so a rule +/// table stays compact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CountryRules { + default: Acquisition, + overrides: BTreeMap, +} + +impl CountryRules { + /// Rules with `default` for every permission and no per-permission override. + /// Groups in `permissions.yaml` are built from this plus [`with_rule`]. + /// + /// [`with_rule`]: Self::with_rule + #[must_use] + pub fn with_default(default: Acquisition) -> Self { + Self { + default, + overrides: BTreeMap::new(), + } + } + + /// Sets the acquisition rule for a single permission, overriding the default. + #[must_use] + pub fn with_rule(mut self, permission: Permission, acquisition: Acquisition) -> Self { + self.overrides.insert(permission.index(), acquisition); + self + } + + /// The acquisition rule for `permission`. + #[must_use] + pub fn rule_for(&self, permission: Permission) -> Acquisition { + self.overrides + .get(&permission.index()) + .copied() + .unwrap_or(self.default) + } +} + +/// Which Data Uses a signal revokes. +#[derive(Debug, Clone, Default)] +enum RevokeSet { + /// The signal revokes nothing. + #[default] + None, + /// The signal revokes every Data Use (the map bounds what a revoke drops). + All, + /// The signal revokes only the listed Data Uses. + Set(PermissionSet), +} + +/// How each session signal maps onto permissions, parsed from the `signals` +/// section of `permissions.yaml`. +/// +/// The permission model holds this as data so the consent mapping applies it +/// rather than encoding any signal policy in the code. It is jurisdiction-free: +/// it says only how a decoded signal grants or revokes each Data Use, and the +/// country/region baseline decides the rest. +#[derive(Debug, Clone, Default)] +pub(crate) struct SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. This never + /// lets a TCF record override an opt-out signal: an opt-out always + /// suppresses the Data Uses it revokes. + tcf_authoritative: bool, + /// Permission bit index to the TCF purpose number that grants it. + tcf_purpose: BTreeMap, + /// The signals that constitute a US-style opt-out. + opt_out_sources: Vec, + /// Which Data Uses a US-style opt-out revokes. + opt_out_revokes: RevokeSet, +} + +impl SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. + pub(crate) fn tcf_authoritative(&self) -> bool { + self.tcf_authoritative + } + + /// The TCF purpose number that grants `permission`, or `None` when no purpose + /// maps to it. + pub(crate) fn tcf_purpose(&self, permission: Permission) -> Option { + self.tcf_purpose.get(&permission.index()).copied() + } + + /// The signals that constitute a US-style opt-out. + pub(crate) fn opt_out_sources(&self) -> &[OptOutSource] { + &self.opt_out_sources + } + + /// Whether a US-style opt-out revokes `permission`. + pub(crate) fn opt_out_revokes(&self, permission: Permission) -> bool { + match &self.opt_out_revokes { + RevokeSet::None => false, + RevokeSet::All => true, + RevokeSet::Set(set) => set.contains(permission), + } + } +} + +/// Errors when a Data Use is granted by more than one TCF purpose, because the +/// grant-and-revoke rule needs a single purpose to answer for each Data Use. +fn ensure_none_signal_duplicate( + previous: Option, + data_use: &str, +) -> Result<(), PermissionsError> { + match previous { + None => Ok(()), + Some(_) => Err(PermissionsError::DuplicateTcfDataUse { + name: data_use.to_owned(), + }), + } +} + +/// Builds a validated [`SignalPolicy`] from the parsed `signals` section, +/// erroring when it names an unknown Data Use or revoke rule. +fn build_signal_policy(spec: &SignalsSpec) -> Result { + let mut policy = SignalPolicy::default(); + if let Some(tcf) = &spec.tcf { + policy.tcf_authoritative = tcf.authoritative; + for (purpose, data_uses) in &tcf.purposes { + for data_use in data_uses.identifiers() { + let permission = Permission::from_identifier(data_use).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: data_use.clone(), + } + })?; + let previous = policy.tcf_purpose.insert(permission.index(), *purpose); + ensure_none_signal_duplicate(previous, data_use)?; + } + } + } + if let Some(opt_out) = &spec.us_opt_out { + policy.opt_out_sources = opt_out.sources.clone(); + policy.opt_out_revokes = match &opt_out.revokes { + RevokeSpec::Keyword(keyword) if keyword == "all" => RevokeSet::All, + RevokeSpec::Keyword(other) => { + return Err(PermissionsError::UnknownRevoke { + value: other.clone(), + }); + } + RevokeSpec::List(names) => { + let mut set = PermissionSet::none(); + for name in names { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { name: name.clone() } + })?; + set = set.with(permission); + } + RevokeSet::Set(set) + } + }; + } + Ok(policy) +} + +/// The place tree from `permissions.yaml`, flattened for lookup. +/// +/// `by_country` is keyed on the ISO 3166-1 alpha-2 code a geo provider returns +/// (upper-cased). `by_region` keeps the finer rules written under a country, +/// keyed by country and region (for example a US state), which take precedence +/// over the country entry. `default_rules` is the top node's group, the answer +/// for a request whose country and region are both unlisted, and for a request +/// with no place at all. +#[derive(Debug, Clone, Default)] +pub struct PermissionMaps { + by_country: BTreeMap, + by_region: BTreeMap, + jurisdiction_by_country: BTreeMap, + jurisdiction_by_region: BTreeMap, + default_rules: Option, + default_jurisdiction: Option, + signals: SignalPolicy, +} + +/// The default permission rules, compiled into the build from the repository's +/// vanilla sample in `config/permissions`. A deployer chooses or replaces the +/// compiled-in file to set the default policy; it is not read at runtime. +const DEFAULT_PERMISSION_RULES: &str = include_str!("../../../config/permissions/vanilla.yaml"); + +/// Builds the upper-cased `COUNTRY:REGION` key for [`PermissionMaps::by_region`]. +fn region_key(country: &str, region: &str) -> String { + format!( + "{}:{}", + country.to_ascii_uppercase(), + region.to_ascii_uppercase() + ) +} + +impl PermissionMaps { + /// Builds an empty map set with no country or region entries. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// The signal-to-permission policy parsed from the `signals` section of + /// `permissions.yaml`. The consent mapping reads this rather than encoding + /// any signal policy in the code. + #[must_use] + pub(crate) fn signals(&self) -> &SignalPolicy { + &self.signals + } + + /// Registers explicit rules for an ISO 3166-1 alpha-2 country code. + #[must_use] + pub fn with_country(mut self, iso_code: &str, rules: CountryRules) -> Self { + self.by_country.insert(iso_code.to_ascii_uppercase(), rules); + self + } + + /// Registers explicit rules for a region within a country, keyed by the ISO + /// 3166-1 alpha-2 country and the geo provider's region code (for example + /// `US` and `CA`). + /// + /// A region entry takes precedence over the country entry, so a deployer can + /// vary a single state or province on top of the country baseline. + #[must_use] + pub fn with_region(mut self, iso_country: &str, region: &str, rules: CountryRules) -> Self { + self.by_region + .insert(region_key(iso_country, region), rules); + self + } + + /// Registers the top node's rules, which apply to a request whose country + /// and region are both unlisted, and to a request with no place at all. + /// + /// A map built without one resolves such a request at the requires-signal + /// floor, which is what [`PermissionMaps::empty`] does. Every map parsed + /// from a `permissions.yaml` has one, because the top node's `group` is + /// required. + #[must_use] + pub fn with_default_rules(mut self, rules: CountryRules) -> Self { + self.default_rules = Some(rules); + self + } + + /// Registers the top node's jurisdiction, the consent handling for a + /// visitor whose place could not be resolved. + #[must_use] + pub fn with_default_jurisdiction(mut self, jurisdiction: Jurisdiction) -> Self { + self.default_jurisdiction = Some(jurisdiction); + self + } + + /// The jurisdiction the policy declares for a visitor whose place the geo + /// provider could not resolve, taken from the top of the `rules:` tree. + /// + /// The consent gates resolve a jurisdiction from the request's place, so + /// with no place they would resolve [`Jurisdiction::Unknown`] and fail + /// closed even where the policy has declared what to do. This is that + /// declaration. A map with no top node (see [`PermissionMaps::empty`]) + /// reports [`Jurisdiction::Unknown`]. + #[must_use] + pub fn default_jurisdiction(&self) -> Jurisdiction { + self.default_jurisdiction.clone().unwrap_or_default() + } + + /// The jurisdiction that applies to `country` and `region`. + /// + /// Walks the tree the same way [`rules_or_default`](Self::rules_or_default) + /// does: the region when it is listed, otherwise the country, otherwise the + /// top node. Inheritance is settled when the file is parsed, so every + /// listed place already carries the jurisdiction it inherits. + #[must_use] + pub fn jurisdiction_for(&self, country: Option<&str>, region: Option<&str>) -> Jurisdiction { + if let (Some(country), Some(region)) = (country, region) + && let Some(jurisdiction) = self + .jurisdiction_by_region + .get(®ion_key(country, region)) + { + return jurisdiction.clone(); + } + if let Some(jurisdiction) = country + .map(str::to_ascii_uppercase) + .and_then(|code| self.jurisdiction_by_country.get(&code)) + { + return jurisdiction.clone(); + } + self.default_jurisdiction() + } + + /// The built-in default rules, parsed from the embedded `permissions.yaml` + /// (see `DEFAULT_PERMISSION_RULES`). + /// + /// The parse runs once per instance and the result is cached. + /// + /// # Panics + /// + /// Panics if the embedded `permissions.yaml` fails to parse. The file is a + /// build-time constant covered by tests, so a panic means the committed file + /// is malformed, not a runtime condition. + #[must_use] + pub fn standard() -> &'static Self { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + Self::from_yaml(DEFAULT_PERMISSION_RULES) + .expect("should parse the embedded default permissions.yaml") + }) + } + + /// Builds the maps from a `permissions.yaml` document: named `groups`, the + /// `rules` tree of places, and the `signals` section that maps each session + /// signal onto Data Uses. + /// + /// # Errors + /// + /// Returns [`PermissionsError`] when the YAML is malformed, the top of the + /// `rules` tree omits `group` or `jurisdiction`, a node below the top is + /// neither a group name nor a block carrying `group`, a node names an + /// unknown group, permission, or acquisition flag, two sibling place codes + /// name the same place, or a signal's revoke list names an unknown Data + /// Use. + pub fn from_yaml(yaml: &str) -> Result { + let file: RulesFile = + serde_yaml_ng::from_str(yaml).map_err(|error| PermissionsError::Parse { + message: error.to_string(), + })?; + + // Build every named group into its CountryRules. + let mut groups: BTreeMap = BTreeMap::new(); + for (name, flags) in &file.groups { + groups.insert(name.clone(), group_rules(name, flags)?); + } + + let mut maps = Self::empty(); + build_rules_tree(&mut maps, &groups, &file.rules)?; + maps.signals = build_signal_policy(&file.signals)?; + Ok(maps) + } + + /// Returns the rules written for `country` and `region` exactly, preferring + /// a region entry, then the country entry, or `None` when neither is listed. + /// + /// This is the literal tree lookup with no fallback to the top node. Use + /// [`rules_or_default`](Self::rules_or_default) for the resolution a + /// request actually gets. + #[must_use] + pub fn rules_for(&self, country: Option<&str>, region: Option<&str>) -> Option<&CountryRules> { + if let (Some(country), Some(region)) = (country, region) + && let Some(rules) = self.by_region.get(®ion_key(country, region)) + { + return Some(rules); + } + country + .map(str::to_ascii_uppercase) + .and_then(|code| self.by_country.get(&code)) + } + + /// The rules a request resolves to: its region, else its country, else the + /// top node of the tree. + /// + /// Returns `None` only for a map with no top node, which the caller treats + /// as the requires-signal floor. Every map parsed from a `permissions.yaml` + /// has one, because the top node's `group` is required, so this is + /// unreachable in a deployment and exists for maps built by hand in tests. + pub(crate) fn rules_or_default( + &self, + country: Option<&str>, + region: Option<&str>, + ) -> Option<&CountryRules> { + self.rules_for(country, region) + .or(self.default_rules.as_ref()) + } + + /// Resolves the permission state for a request: the place baseline + /// augmented by a session signal. + /// + /// `country` and `region` are what a geo provider returns (`region` may be + /// `None`). Whatever the tree does not answer falls back to the node above, + /// ending at the top node, so an unlisted country and a request with no + /// country at all both resolve to the top node's group. `signal` maps each + /// permission to a [`ConsentSignal`]; the caller derives it from its consent + /// model so this module stays independent of how a signal is decoded. A + /// `Granted` baseline is set unless the signal is `Revoke`, a + /// `RequiresSignal` baseline is set only on `Grant`, and `Denied` is never + /// set. + #[must_use] + pub fn resolve_with( + &self, + country: Option<&str>, + region: Option<&str>, + signal: impl Fn(Permission) -> ConsentSignal, + ) -> PermissionState { + Self::resolve_rules(self.rules_or_default(country, region), signal) + } + + /// Resolves every permission at the requires-signal floor, whatever the + /// policy tree says. + /// + /// This is the state for a geo provider that reported an outright lookup + /// failure. The request's place is unknown in a way the policy's top node + /// must not paper over, so nothing is set unless the session's signals + /// grant it. + #[must_use] + pub fn floor_with(signal: impl Fn(Permission) -> ConsentSignal) -> PermissionState { + Self::resolve_rules(None, signal) + } + + /// Applies `signal` against `rules`, or against the requires-signal floor + /// when no rules resolved. + fn resolve_rules( + rules: Option<&CountryRules>, + signal: impl Fn(Permission) -> ConsentSignal, + ) -> PermissionState { + let acquisition = + |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); + let set = Permission::all() + .filter( + |&permission| match (acquisition(permission), signal(permission)) { + (Acquisition::Denied, _) => false, + (Acquisition::Granted, ConsentSignal::Revoke) => false, + (Acquisition::Granted, _) => true, + (Acquisition::RequiresSignal, ConsentSignal::Grant) => true, + (Acquisition::RequiresSignal, _) => false, + }, + ) + .collect(); + PermissionState { set } + } + + /// The baseline permission state for a country and region with no session + /// signal. + /// + /// Permissions exist without a consent model, so this is the set of + /// `Granted` permissions for the place (or the top node it falls back to), + /// and is what a request resolves to when no signal is present. + #[must_use] + pub fn baseline(&self, country: Option<&str>, region: Option<&str>) -> PermissionState { + self.resolve_with(country, region, |_| ConsentSignal::Neutral) + } + + /// Convenience over [`resolve_with`](Self::resolve_with) for a boolean + /// signal with no region and no revocation: a `true` grants the permission + /// and a `false` is neutral. + #[must_use] + pub fn resolve( + &self, + country: Option<&str>, + signal: impl Fn(Permission) -> bool, + ) -> PermissionState { + self.resolve_with(country, None, |permission| { + if signal(permission) { + ConsentSignal::Grant + } else { + ConsentSignal::Neutral + } + }) + } +} + +/// The permissions Trusted Server currently has set for a request. +/// +/// A provider executes only when [`all_set`](Self::all_set) of its required +/// permissions returns `true`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PermissionState { + set: PermissionSet, +} + +impl PermissionState { + /// Builds a state in which exactly the permissions in `set` are set, for + /// tests and callers that compute the set directly. + #[must_use] + pub const fn new(set: PermissionSet) -> Self { + Self { set } + } + + /// Whether a single permission is set. + #[must_use] + pub const fn is_set(&self, permission: Permission) -> bool { + self.set.contains(permission) + } + + /// Whether every permission in `required` is set. An empty requirement is + /// always satisfied, so a provider that requires nothing always runs. + #[must_use] + pub const fn all_set(&self, required: PermissionSet) -> bool { + self.set.contains_all(required) + } + + /// The full set of permissions that are set, for a provider that adapts its + /// behavior to whatever is present. + #[must_use] + pub const fn permissions(&self) -> PermissionSet { + self.set + } + + /// The resolved state as the JSON the page receives in + /// `window.tsjs.permissions`. + /// + /// Names are the [`Permission::as_str`] Data Use identifiers, sorted so the + /// same state always serializes to the same bytes whatever order the set + /// was built in. An empty state + /// renders as `{"set":[]}`, which is an answer (nothing is set) rather than + /// a missing value, so page code never has to tell the two apart. + /// + /// This is the only place the page shape is spelled, so no caller writes + /// the JSON by hand. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::permissions::{ + /// Permission, PermissionSet, PermissionState, + /// }; + /// + /// let state = PermissionState::new( + /// PermissionSet::none().with(Permission::StoreOnDevice), + /// ); + /// assert_eq!( + /// state.page_json(), + /// r#"{"set":["necessary.operations.storage"]}"# + /// ); + /// + /// assert_eq!(PermissionState::default().page_json(), r#"{"set":[]}"#); + /// ``` + #[must_use] + pub fn page_json(&self) -> String { + let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); + names.sort_unstable(); + serde_json::json!({ "set": names }).to_string() + } +} + +// --------------------------------------------------------------------------- +// permissions.yaml parsing +// --------------------------------------------------------------------------- + +/// The shape of a `permissions.yaml` document. +#[derive(Debug, Deserialize)] +struct RulesFile { + /// Named permission baselines, keyed by group name. Each group is a flat map + /// of per-permission flags, with an optional `default` shorthand for any + /// permission it omits. + #[serde(default)] + groups: BTreeMap>, + /// The `rules` tree of places. Walked by [`build_rules_tree`] rather than + /// deserialized into a fixed shape, because every key below the reserved + /// words is a place code chosen by the policy owner. + #[serde(default)] + rules: Value, + /// How each session signal maps onto Data Uses. + #[serde(default)] + signals: SignalsSpec, +} + +/// The `signals` section: how each signal source maps onto Data Uses. Parsed +/// into a [`SignalPolicy`] by [`build_signal_policy`]. +#[derive(Debug, Default, Deserialize)] +struct SignalsSpec { + /// The TCF record mapping, or `None` when the file declares no TCF policy. + #[serde(default)] + tcf: Option, + /// The US-style opt-out mapping, or `None` when none is declared. + #[serde(default)] + us_opt_out: Option, +} + +/// The `signals.tcf` block. +#[derive(Debug, Deserialize)] +struct TcfSignalSpec { + /// Whether a present TCF record's grants and revokes apply. + #[serde(default = "default_true")] + authoritative: bool, + /// TCF purpose number to the Data Use, or list of Data Uses, it grants + /// (and revokes when the record does not consent to that purpose). + #[serde(default)] + purposes: BTreeMap, +} + +/// One Data Use, or a list of Data Uses, granted by a single TCF purpose. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum DataUseList { + /// A single Data Use identifier. + One(String), + /// A list of Data Use identifiers. + Many(Vec), +} + +impl DataUseList { + /// The Data Use identifiers this value names, in written order. + fn identifiers(&self) -> &[String] { + match self { + DataUseList::One(one) => core::slice::from_ref(one), + DataUseList::Many(many) => many, + } + } +} + +/// The `signals.us_opt_out` block. +#[derive(Debug, Deserialize)] +struct OptOutSpec { + /// The signals that constitute a US-style opt-out. + #[serde(default)] + sources: Vec, + /// Which Data Uses the opt-out revokes. + #[serde(default)] + revokes: RevokeSpec, +} + +/// A `revokes` value: the keyword `all`, or an explicit list of Data Uses. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RevokeSpec { + /// A bare keyword, expected to be `all`. + Keyword(String), + /// An explicit list of Data Use identifiers. + List(Vec), +} + +impl Default for RevokeSpec { + fn default() -> Self { + RevokeSpec::List(Vec::new()) + } +} + +/// A single US-style opt-out signal source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OptOutSource { + /// The `Sec-GPC` request header (Global Privacy Control). + Gpc, + /// A GPP US sale opt-out. + GppSaleOptOut, + /// A US Privacy string sale opt-out. + UsPrivacyOptOut, +} + +/// Default for `#[serde(default = ...)]` on a `bool` field that should be `true`. +fn default_true() -> bool { + true +} + +/// The reserved key naming a node's permission group. +const KEY_GROUP: &str = "group"; + +/// The reserved key naming the top node's jurisdiction. +const KEY_JURISDICTION: &str = "jurisdiction"; + +/// The reserved key holding a node's per-permission overrides. +const KEY_PERMISSIONS: &str = "permissions"; + +/// Resolves an acquisition rule name to its [`Acquisition`]. +fn parse_acquisition(value: &str) -> Result { + match value { + "granted" => Ok(Acquisition::Granted), + "requires_signal" => Ok(Acquisition::RequiresSignal), + "denied" => Ok(Acquisition::Denied), + other => Err(PermissionsError::UnknownAcquisition { + value: other.to_owned(), + }), + } +} + +/// Builds a group's [`CountryRules`] from its flag map. Each key names a +/// permission and its flag; an optional `default` key sets any permission the +/// group omits. A group without a `default` must list every permission, so its +/// meaning is fully explicit (this is how the shipped groups are written). +fn group_rules( + name: &str, + flags: &BTreeMap, +) -> Result { + let default = flags + .get("default") + .map(|value| parse_acquisition(value)) + .transpose()?; + // With no `default`, every permission must be listed, so this placeholder is + // never consulted once completeness is checked below. + let mut rules = CountryRules::with_default(default.unwrap_or(Acquisition::Denied)); + let mut listed = PermissionSet::none(); + for (key, value) in flags { + if key == "default" { + continue; + } + let permission = Permission::from_identifier(key) + .ok_or_else(|| PermissionsError::UnknownPermission { name: key.clone() })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + listed = listed.with(permission); + } + if default.is_none() { + for permission in Permission::all() { + if !listed.contains(permission) { + return Err(PermissionsError::IncompleteGroup { + group: name.to_owned(), + permission: permission.to_string(), + }); + } + } + } + Ok(rules) +} + +/// Looks up a group by name, erroring when a rule references a group that is not +/// defined. +fn resolve_group( + groups: &BTreeMap, + name: &str, +) -> Result { + groups + .get(name) + .cloned() + .ok_or_else(|| PermissionsError::UnknownGroup { + name: name.to_owned(), + }) +} + +/// Applies a rule's per-permission acquisition overrides on top of its group's +/// rules. Each entry maps a Data Use to `granted`, `requires_signal`, or +/// `denied`, overriding the group's baseline for that Data Use, so any +/// acquisition (not just grant or deny) is expressible per rule. +fn apply_modifications( + mut rules: CountryRules, + modifications: &BTreeMap, +) -> Result { + for (name, value) in modifications { + let permission = Permission::from_identifier(name).ok_or_else(|| { + PermissionsError::UnknownPermission { + name: name.to_owned(), + } + })?; + rules = rules.with_rule(permission, parse_acquisition(value)?); + } + Ok(rules) +} + +/// One node of the `rules` tree, resolved into its rules, the jurisdiction it +/// names (when it names one), and the child place codes written beside them. +struct RuleNode<'a> { + /// The node's group, with any `permissions` overrides applied. + rules: CountryRules, + /// The `jurisdiction:` value written on this node, or `None` when the node + /// inherits the one above it. + jurisdiction: Option<&'a str>, + /// The child place codes, in document order, each still unparsed. + children: Vec<(&'a str, &'a Value)>, +} + +/// Parses one node of the `rules` tree. +/// +/// A node written as a plain string is the shorthand: the string is its group, +/// it names no jurisdiction, and it has no children. A node written as a block +/// must carry a `group:` line, may carry a `jurisdiction:` line and a +/// `permissions:` map of overrides, and may carry child place codes beside +/// them. `path` names the node for error messages (`"US"`, `"US/CA"`, or the +/// top of the tree). +fn parse_rule_node<'a>( + groups: &BTreeMap, + path: &str, + value: &'a Value, +) -> Result, PermissionsError> { + match value { + Value::String(name) => Ok(RuleNode { + rules: resolve_group(groups, name)?, + jurisdiction: None, + children: Vec::new(), + }), + Value::Mapping(map) => { + let group = map.get(KEY_GROUP).and_then(Value::as_str).ok_or_else(|| { + PermissionsError::MissingGroup { + path: path.to_owned(), + } + })?; + let mut rules = resolve_group(groups, group)?; + if let Some(overrides) = map.get(KEY_PERMISSIONS) { + let overrides: BTreeMap = + serde_yaml_ng::from_value(overrides.clone()).map_err(|error| { + PermissionsError::Parse { + message: format!("`{KEY_PERMISSIONS}` under `{path}`: {error}"), + } + })?; + rules = apply_modifications(rules, &overrides)?; + } + let jurisdiction = + match map.get(KEY_JURISDICTION) { + Some(value) => Some(value.as_str().ok_or_else(|| { + PermissionsError::UnknownJurisdiction { + value: format!("{value:?}"), + } + })?), + None => None, + }; + let mut children = Vec::new(); + for (key, child) in map { + let key = key.as_str().ok_or_else(|| PermissionsError::InvalidRule { + path: path.to_owned(), + })?; + if key == KEY_GROUP || key == KEY_PERMISSIONS || key == KEY_JURISDICTION { + continue; + } + children.push((key, child)); + } + Ok(RuleNode { + rules, + jurisdiction, + children, + }) + } + _ => Err(PermissionsError::InvalidRule { + path: path.to_owned(), + }), + } +} + +/// Resolves a `jurisdiction:` value written on the node at `path`. +/// +/// `region` is the node's own ISO 3166-2 code, or `None` for the top of the +/// tree and for a country. `us-state` carries no code of its own, because the +/// node naming it is the state, so it is rejected wherever there is no region +/// to name. +fn parse_jurisdiction( + path: &str, + region: Option<&str>, + value: &str, +) -> Result { + if value == "us-state" && region.is_none() { + return Err(PermissionsError::MisplacedUsState { + path: path.to_owned(), + }); + } + Jurisdiction::from_policy_name(value, region).ok_or_else(|| { + PermissionsError::UnknownJurisdiction { + value: value.to_owned(), + } + }) +} + +/// Walks the `rules` tree into `maps`: the top node, then the country nodes +/// under it, then the region nodes under each country. +/// +/// Each node's group and jurisdiction are settled here, so a node that names +/// neither is stored carrying what it inherits from the node above it. The top +/// node must name both, which is what makes inheritance always terminate. +/// +/// The tree is three levels deep, because a geo provider returns a country and +/// a region and nothing finer, so a place written under a region is rejected +/// rather than silently ignored. +fn build_rules_tree( + maps: &mut PermissionMaps, + groups: &BTreeMap, + rules: &Value, +) -> Result<(), PermissionsError> { + const TOP: &str = "the top of the tree"; + + if !matches!(rules, Value::Mapping(_)) { + return Err(PermissionsError::MissingGroup { + path: TOP.to_owned(), + }); + } + let top = parse_rule_node(groups, TOP, rules)?; + let name = top + .jurisdiction + .ok_or(PermissionsError::MissingJurisdiction)?; + let top_jurisdiction = parse_jurisdiction(TOP, None, name)?; + maps.default_rules = Some(top.rules); + maps.default_jurisdiction = Some(top_jurisdiction.clone()); + + // Place codes are matched without regard to case at lookup, so two + // spellings of one place would silently overwrite each other. Reject the + // collision instead, among the siblings at each level. + let mut seen_countries: BTreeMap = BTreeMap::new(); + for (country, node) in top.children { + check_duplicate(&mut seen_countries, country)?; + let node = parse_rule_node(groups, country, node)?; + let jurisdiction = match node.jurisdiction { + Some(name) => parse_jurisdiction(country, None, name)?, + None => top_jurisdiction.clone(), + }; + let code = country.to_ascii_uppercase(); + maps.by_country.insert(code.clone(), node.rules); + maps.jurisdiction_by_country + .insert(code, jurisdiction.clone()); + + let mut seen_regions: BTreeMap = BTreeMap::new(); + for (region, child) in node.children { + let path = format!("{country}/{region}"); + check_duplicate(&mut seen_regions, region)?; + let child = parse_rule_node(groups, &path, child)?; + if !child.children.is_empty() { + return Err(PermissionsError::NestedTooDeep { path }); + } + let child_jurisdiction = match child.jurisdiction { + Some(name) => parse_jurisdiction(&path, Some(region), name)?, + None => jurisdiction.clone(), + }; + let key = region_key(country, region); + maps.by_region.insert(key.clone(), child.rules); + maps.jurisdiction_by_region.insert(key, child_jurisdiction); + } + } + Ok(()) +} + +/// Records a place code among its siblings, erroring when another spelling of +/// the same code is already there. +fn check_duplicate<'a>( + seen: &mut BTreeMap, + key: &'a str, +) -> Result<(), PermissionsError> { + if let Some(first) = seen.insert(key.to_ascii_uppercase(), key) { + return Err(PermissionsError::DuplicateRule { + first: first.to_owned(), + second: key.to_owned(), + }); + } + Ok(()) +} + +/// Parses the compiled `permissions.yaml`, so startup validation can reject a +/// malformed policy with a configuration error instead of panicking on the +/// first lookup. +/// +/// # Errors +/// +/// Returns the same [`PermissionsError`] values [`PermissionMaps::from_yaml`] +/// does, most usefully a top node that omits `group` or `jurisdiction`. +pub fn validate_default_policy() -> Result<(), PermissionsError> { + PermissionMaps::from_yaml(DEFAULT_PERMISSION_RULES).map(|_| ()) +} + +/// An error parsing a `permissions.yaml` document. +#[derive(Debug, derive_more::Display)] +pub enum PermissionsError { + /// Two sibling place codes are the same place spelled differently. + #[display("place codes `{first}` and `{second}` name the same place, so keep one")] + DuplicateRule { first: String, second: String }, + /// A node of the `rules` tree carried no `group`. + #[display( + "the rule for `{path}` needs a `group:` line (or write the group name on its own, as `GB: gdpr-uk`)" + )] + MissingGroup { path: String }, + /// The top of the `rules` tree carried no `jurisdiction`. + #[display( + "the top of the `rules` tree needs a `jurisdiction:` line, naming the consent handling for a visitor whose place cannot be resolved" + )] + MissingJurisdiction, + /// A node with no region of its own named `us-state`. + #[display( + "the rule for `{path}` names `jurisdiction: us-state`, which only a region may name, because the region is the state" + )] + MisplacedUsState { path: String }, + /// A `jurisdiction` value named something the consent model cannot + /// represent. + #[display( + "unknown jurisdiction `{value}` (expected gdpr, us-state, non-regulated, or unknown)" + )] + UnknownJurisdiction { value: String }, + /// A node of the `rules` tree was neither a group name nor a block. + #[display("the rule for `{path}` must be a group name or a block with a `group:` line")] + InvalidRule { path: String }, + /// A place was written under a region, deeper than a geo provider resolves. + #[display( + "the rule for `{path}` has places written under it; the tree stops at a region, because that is the finest place a geo provider returns" + )] + NestedTooDeep { path: String }, + /// The YAML was malformed or did not match the expected shape. + #[display("failed to parse permission rules: {message}")] + Parse { message: String }, + /// A group without a `default` did not list every permission. + #[display( + "permission group `{group}` has no `default` and is missing a flag for `{permission}` (list every permission, or add a `default`)" + )] + IncompleteGroup { group: String, permission: String }, + /// A rule referenced a group that is not defined. + #[display("unknown permission group `{name}`")] + UnknownGroup { name: String }, + /// A permission flag or modification named an unknown permission. + #[display("unknown permission `{name}`")] + UnknownPermission { name: String }, + /// A Data Use appeared under more than one TCF purpose in `signals.tcf`. + #[display( + "Data Use `{name}` is granted by more than one TCF purpose; map each Data Use to a single purpose" + )] + DuplicateTcfDataUse { name: String }, + /// An acquisition rule was not `granted`, `requires_signal`, or `denied`. + #[display("unknown acquisition rule `{value}` (expected granted, requires_signal, or denied)")] + UnknownAcquisition { value: String }, + /// A `signals` opt-out `revokes` value was neither `all` nor a list. + #[display("unknown revoke rule `{value}` (expected `all` or a list of Data Uses)")] + UnknownRevoke { value: String }, +} + +impl core::error::Error for PermissionsError {} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn permission_set_membership_and_subset() { + let set = PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds); + + assert!(set.contains(Permission::StoreOnDevice)); + assert!(set.contains(Permission::SelectBasicAds)); + assert!( + !set.contains(Permission::SelectPersonalisedAds), + "an absent permission should not be reported as present" + ); + + let required = PermissionSet::none().with(Permission::StoreOnDevice); + assert!(set.contains_all(required), "a subset should be contained"); + assert!( + !required.contains_all(set), + "a superset is not contained in a subset" + ); + assert!( + set.contains_all(PermissionSet::none()), + "the empty requirement is always satisfied" + ); + } + + #[test] + fn permission_set_iterates_in_bit_index_order() { + let set = PermissionSet::none() + .with(Permission::SelectBasicAds) + .with(Permission::StoreOnDevice); + let order: Vec<&str> = set.iter().map(Permission::as_str).collect(); + assert_eq!( + order, + vec![ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual" + ], + "iteration should be in stable bit-index order" + ); + } + + #[test] + fn page_json_lists_set_permissions_sorted_by_name() { + // Arrange: a state whose bit-index order is the reverse of its name + // order, so the sort is what the assertion sees. + let state = PermissionState::new( + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::SelectBasicAds), + ); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": [ + "advertising_marketing.first_party.contextual", + "necessary.operations.storage", + ] + }) + .to_string(), + "should list every set permission by Data Use name, sorted" + ); + } + + #[test] + fn page_json_of_an_empty_state_is_an_empty_set() { + // Arrange + let state = PermissionState::default(); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ "set": [] }).to_string(), + "an empty state should render as an empty set, not as nothing" + ); + } + + #[test] + fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { + // Empty maps and no top node: every permission is the requires-signal + // floor, set only when a signal grants it. + let maps = PermissionMaps::default(); + + let denied = maps.resolve(Some("GB"), |_| false); + assert!( + !denied.is_set(Permission::StoreOnDevice), + "the floor should not set necessary.operations.storage without a signal" + ); + + let granted = maps.resolve(Some("GB"), |p| p == Permission::StoreOnDevice); + assert!( + granted.is_set(Permission::StoreOnDevice), + "the floor should set necessary.operations.storage once a signal grants it" + ); + } + + #[test] + fn the_floor_ignores_the_top_node_even_where_one_grants() { + // A policy whose top node grants everything, so the only way the floor + // can leave the permission unset is by never consulting the tree. This + // is the geo lookup failure case. + let maps = PermissionMaps::empty() + .with_default_rules(CountryRules::with_default(Acquisition::Granted)); + assert!( + maps.baseline(None, None).is_set(Permission::StoreOnDevice), + "the top node grants storage, or this test proves nothing" + ); + assert!( + !PermissionMaps::floor_with(|_| ConsentSignal::Neutral) + .is_set(Permission::StoreOnDevice), + "the floor must not fall back to the top node" + ); + } + + #[test] + fn an_unlisted_country_uses_the_top_node() { + // A map whose top node grants, so an unlisted country is answered by + // it rather than by the floor. + let maps = PermissionMaps::empty() + .with_default_rules(CountryRules::with_default(Acquisition::Granted)); + assert!( + maps.resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "an unlisted country should resolve at the top node" + ); + // No country at all resolves the same way. + assert!( + maps.resolve(None, |_| false) + .is_set(Permission::StoreOnDevice), + "no country should resolve at the top node too" + ); + // With no top node the requires-signal floor sets nothing. + assert!( + !PermissionMaps::empty() + .resolve(None, |_| false) + .is_set(Permission::StoreOnDevice), + "with no top node, an unlisted country sets nothing without a signal" + ); + } + + #[test] + fn a_matching_country_is_used_over_the_top_node() { + // US grants; the top node requires a signal. + let maps = PermissionMaps::empty() + .with_country("us", CountryRules::with_default(Acquisition::Granted)) + .with_default_rules(CountryRules::with_default(Acquisition::RequiresSignal)); + assert!( + maps.resolve(Some("US"), |_| false) + .is_set(Permission::StoreOnDevice), + "a country with its own rule uses it, not the top node" + ); + assert!( + !maps + .resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "an unlisted country falls back to the top node" + ); + } + + #[test] + fn per_permission_override_beats_the_country_default() { + // Granted by default, but deny necessary.operations.storage specifically. + let rules = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + let maps = PermissionMaps::empty().with_country("zz", rules); + let state = maps.resolve(Some("ZZ"), |_| true); + + assert!( + !state.is_set(Permission::StoreOnDevice), + "an explicit Denied override should beat the granted default" + ); + assert!( + state.is_set(Permission::SelectBasicAds), + "other permissions should still follow the granted default" + ); + } + + #[test] + fn all_set_gates_on_the_required_set() { + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)); + + assert!( + state.all_set(PermissionSet::none()), + "a provider requiring nothing always runs" + ); + assert!( + state.all_set(PermissionSet::none().with(Permission::StoreOnDevice)), + "a set requirement is satisfied" + ); + assert!( + !state.all_set(PermissionSet::none().with(Permission::SelectPersonalisedAds)), + "an unset requirement is not satisfied" + ); + } + + #[test] + fn with_default_sets_the_baseline_acquisition() { + // A granted default is set with no signal required. + let granted = PermissionMaps::empty() + .with_country("zz", CountryRules::with_default(Acquisition::Granted)); + assert!( + granted + .resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "a granted default should set with no signal" + ); + // A requires-signal default is set only once a signal grants it. + let opt_in = PermissionMaps::empty().with_country( + "zz", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !opt_in + .resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should not be set without a signal" + ); + assert!( + opt_in + .resolve(Some("ZZ"), |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "a requires-signal default should set once a signal grants it" + ); + } + + #[test] + fn granted_and_denied_rules_ignore_signals() { + // A Granted rule is set even when no signal is present. + let granted = CountryRules::with_default(Acquisition::RequiresSignal) + .with_rule(Permission::StoreOnDevice, Acquisition::Granted); + assert!( + PermissionMaps::empty() + .with_country("zz", granted) + .resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "a Granted rule is set with no signal" + ); + + // A Denied rule is never set, even when every signal grants. + let denied = CountryRules::with_default(Acquisition::Granted) + .with_rule(Permission::StoreOnDevice, Acquisition::Denied); + assert!( + !PermissionMaps::empty() + .with_country("zz", denied) + .resolve(Some("ZZ"), |_| true) + .is_set(Permission::StoreOnDevice), + "a Denied rule is never set even with a signal" + ); + } + + #[test] + fn standard_maps_eu_requires_signal_and_uk_grants_storage() { + let maps = PermissionMaps::standard(); + assert!( + !maps + .resolve(Some("DE"), |_| false) + .is_set(Permission::StoreOnDevice), + "an EU country should not set necessary.operations.storage without a signal" + ); + assert!( + maps.resolve(Some("DE"), |p| p == Permission::StoreOnDevice) + .is_set(Permission::StoreOnDevice), + "an EU country should set necessary.operations.storage once a signal grants it" + ); + assert!( + maps.resolve(Some("GB"), |_| false) + .is_set(Permission::StoreOnDevice), + "the UK should grant necessary.operations.storage without a signal" + ); + } + + #[test] + fn standard_maps_us_and_australia_grant_storage_by_default() { + let maps = PermissionMaps::standard(); + for code in ["US", "AU"] { + assert!( + maps.resolve(Some(code), |_| false) + .is_set(Permission::StoreOnDevice), + "{code} should grant necessary.operations.storage by default" + ); + } + // No default configured, so an unmapped country hits the requires-signal + // floor and sets nothing without a signal. + assert!( + !maps + .resolve(Some("ZZ"), |_| false) + .is_set(Permission::StoreOnDevice), + "an unmapped country with no default sets nothing without a signal" + ); + } + + #[test] + fn resolve_with_revokes_a_granted_permission_on_opt_out() { + // US grants necessary.operations.storage by default; an opt-out signal revokes it. + let maps = PermissionMaps::standard(); + assert!( + maps.baseline(Some("US"), None) + .is_set(Permission::StoreOnDevice), + "the US baseline should set necessary.operations.storage" + ); + let revoked = maps.resolve_with(Some("US"), None, |p| { + if p == Permission::StoreOnDevice { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + }); + assert!( + !revoked.is_set(Permission::StoreOnDevice), + "an opt-out signal should revoke a granted permission" + ); + } + + #[test] + fn a_region_entry_overrides_the_country_baseline() { + // US grants by default; a state can require a signal instead. + let maps = PermissionMaps::standard().clone().with_region( + "US", + "CA", + CountryRules::with_default(Acquisition::RequiresSignal), + ); + assert!( + !maps + .baseline(Some("US"), Some("CA")) + .is_set(Permission::StoreOnDevice), + "the CA region rule should require a signal, overriding the US baseline" + ); + assert!( + maps.baseline(Some("US"), Some("NY")) + .is_set(Permission::StoreOnDevice), + "a state with no region entry should follow the US baseline" + ); + } + + #[test] + fn from_yaml_parses_groups_rules_and_modifications() { + let yaml = r#" +groups: + eu: + default: requires_signal + us: + default: granted +rules: + group: eu + jurisdiction: gdpr + FR: eu + US: + group: us + jurisdiction: non-regulated + CA: + group: eu + jurisdiction: us-state + permissions: + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: denied +"#; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the rules"); + + // Bare group references. + assert!( + !maps + .baseline(Some("FR"), None) + .is_set(Permission::StoreOnDevice), + "FR (eu) requires a signal for device storage" + ); + assert!( + maps.baseline(Some("US"), None) + .is_set(Permission::StoreOnDevice), + "US (us) grants device storage" + ); + + // CA references the eu group, but its permissions map grants + // necessary.operations.storage, overriding the eu baseline. + assert!( + maps.baseline(Some("US"), Some("CA")) + .is_set(Permission::StoreOnDevice), + "the permissions map grants necessary.operations.storage for CA, overriding the eu baseline" + ); + // The permissions map denies advertising_marketing.first_party.contextual, + // so it is not set even when a signal grants it. + assert!( + !maps + .resolve_with(Some("US"), Some("CA"), |_| ConsentSignal::Grant) + .is_set(Permission::SelectBasicAds), + "the permissions map denies advertising_marketing.first_party.contextual even when a signal grants it" + ); + + // An unlisted country falls back to the top node (eu, requires signal). + assert!( + !maps + .baseline(Some("ZZ"), None) + .is_set(Permission::StoreOnDevice), + "an unlisted country should use the top node" + ); + } + + /// A two-group policy the tree tests below vary, with `g` granting + /// everything and `strict` requiring a signal for everything. + const TEST_GROUPS: &str = "groups:\n g:\n default: granted\n \ + strict:\n default: requires_signal\n"; + + #[test] + fn a_shorthand_node_is_a_group_with_nothing_below_it() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: strict + jurisdiction: gdpr + GB: g +" + ); + let maps = PermissionMaps::from_yaml(&yaml).expect("a shorthand node should parse"); + assert_eq!( + maps.rules_for(Some("GB"), None) + .expect("GB should have a rule") + .rule_for(Permission::StoreOnDevice), + Acquisition::Granted, + "the shorthand string should name the node's group" + ); + assert_eq!( + maps.jurisdiction_for(Some("GB"), None), + Jurisdiction::Gdpr, + "a shorthand node should inherit the top node's jurisdiction" + ); + } + + #[test] + fn a_block_node_carries_children_beside_its_group() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: g + jurisdiction: gdpr + US: + group: g + jurisdiction: non-regulated + CA: + group: strict + jurisdiction: us-state +" + ); + let maps = PermissionMaps::from_yaml(&yaml).expect("a block node should parse"); + assert_eq!( + maps.rules_for(Some("US"), Some("CA")) + .expect("US/CA should have a rule") + .rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "the region child should carry its own group" + ); + assert_eq!( + maps.rules_or_default(Some("US"), Some("NV")) + .expect("an unlisted region should fall back to its country") + .rule_for(Permission::StoreOnDevice), + Acquisition::Granted, + "an unlisted region should resolve at its country node" + ); + } + + #[test] + fn from_yaml_rejects_a_block_without_a_group() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: g + jurisdiction: gdpr + US: + jurisdiction: non-regulated +" + ); + let err = + PermissionMaps::from_yaml(&yaml).expect_err("a block with no group should be rejected"); + assert!( + matches!(&err, PermissionsError::MissingGroup { path } if path == "US"), + "should report the missing group for US, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_a_top_node_without_a_group() { + let yaml = format!("{TEST_GROUPS}rules:\n jurisdiction: gdpr\n US: g\n"); + let err = PermissionMaps::from_yaml(&yaml) + .expect_err("a top node with no group should be rejected"); + assert!( + matches!(err, PermissionsError::MissingGroup { .. }), + "should report the missing top group, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_a_top_node_without_a_jurisdiction() { + let yaml = format!("{TEST_GROUPS}rules:\n group: g\n US: g\n"); + let err = PermissionMaps::from_yaml(&yaml) + .expect_err("a top node with no jurisdiction should be rejected"); + assert!( + matches!(err, PermissionsError::MissingJurisdiction), + "should report the missing top jurisdiction, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_an_unknown_jurisdiction() { + let yaml = format!("{TEST_GROUPS}rules:\n group: g\n jurisdiction: ccpa\n"); + let err = PermissionMaps::from_yaml(&yaml) + .expect_err("an unknown jurisdiction should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownJurisdiction { .. }), + "should report the unknown jurisdiction, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_us_state_above_a_region() { + // `us-state` names no code of its own, so only a region can carry it. + let top = format!("{TEST_GROUPS}rules:\n group: g\n jurisdiction: us-state\n"); + assert!( + matches!( + PermissionMaps::from_yaml(&top) + .expect_err("us-state at the top should be rejected"), + PermissionsError::MisplacedUsState { .. } + ), + "the top of the tree names no state" + ); + let country = format!( + "{TEST_GROUPS}\ +rules: + group: g + jurisdiction: gdpr + US: + group: g + jurisdiction: us-state +" + ); + assert!( + matches!( + PermissionMaps::from_yaml(&country) + .expect_err("us-state on a country should be rejected"), + PermissionsError::MisplacedUsState { .. } + ), + "a country names no state" + ); + } + + #[test] + fn from_yaml_rejects_a_place_written_under_a_region() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: g + jurisdiction: gdpr + US: + group: g + CA: + group: g + LA: + group: g +" + ); + let err = PermissionMaps::from_yaml(&yaml).expect_err("a fourth level should be rejected"); + assert!( + matches!(err, PermissionsError::NestedTooDeep { .. }), + "should report the tree being too deep, got {err:?}" + ); + } + + #[test] + fn a_jurisdiction_is_inherited_and_can_be_overridden_at_each_level() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: g + jurisdiction: gdpr + GB: g + US: + group: g + jurisdiction: non-regulated + CA: + group: g + jurisdiction: us-state + NY: g +" + ); + let maps = PermissionMaps::from_yaml(&yaml).expect("should parse the tree"); + assert_eq!( + maps.jurisdiction_for(Some("GB"), None), + Jurisdiction::Gdpr, + "a country naming none should inherit the top node" + ); + assert_eq!( + maps.jurisdiction_for(Some("US"), None), + Jurisdiction::NonRegulated, + "a country naming its own should override the top node" + ); + assert_eq!( + maps.jurisdiction_for(Some("US"), Some("NY")), + Jurisdiction::NonRegulated, + "a region naming none should inherit its country" + ); + assert_eq!( + maps.jurisdiction_for(Some("US"), Some("CA")), + Jurisdiction::UsState("CA".to_owned()), + "a region naming us-state should name itself as the state" + ); + assert_eq!( + maps.jurisdiction_for(Some("ZZ"), None), + Jurisdiction::Gdpr, + "an unlisted country should inherit the top node" + ); + assert_eq!( + maps.default_jurisdiction(), + Jurisdiction::Gdpr, + "no place at all should resolve the top node" + ); + } + + #[test] + fn place_codes_are_matched_without_regard_to_case() { + let yaml = format!( + "{TEST_GROUPS}\ +rules: + group: strict + jurisdiction: gdpr + us: + group: strict + jurisdiction: non-regulated + ca: + group: g + jurisdiction: us-state +" + ); + let maps = PermissionMaps::from_yaml(&yaml).expect("should parse lower-case place codes"); + assert!( + maps.baseline(Some("US"), Some("CA")) + .is_set(Permission::StoreOnDevice), + "an upper-case request should match a lower-case rule" + ); + assert_eq!( + maps.jurisdiction_for(Some("US"), Some("CA")), + Jurisdiction::UsState("CA".to_owned()), + "the state code should be upper-cased whatever case the file uses" + ); + } + + #[test] + fn the_settled_example_resolves_as_documented() { + // The example in the permissions.yaml header, kept here so the file's + // own teaching example is proven rather than asserted. + let yaml = r#" +groups: + gdpr-eu: + default: requires_signal + gdpr-uk: + default: requires_signal + us-notice: + default: granted + us-opt-out: + default: granted +rules: + group: gdpr-eu + jurisdiction: gdpr + GB: gdpr-uk + US: + group: us-notice + jurisdiction: non-regulated + CA: + group: us-opt-out + jurisdiction: us-state + NY: us-notice +"#; + let maps = PermissionMaps::from_yaml(yaml).expect("the header example should parse"); + for (country, region, jurisdiction) in [ + (Some("GB"), None, Jurisdiction::Gdpr), + (Some("US"), None, Jurisdiction::NonRegulated), + (Some("US"), Some("NY"), Jurisdiction::NonRegulated), + ( + Some("US"), + Some("CA"), + Jurisdiction::UsState("CA".to_owned()), + ), + (Some("JP"), None, Jurisdiction::Gdpr), + (None, None, Jurisdiction::Gdpr), + ] { + assert_eq!( + maps.jurisdiction_for(country, region), + jurisdiction, + "{country:?}/{region:?} should resolve as the header documents" + ); + } + assert!( + !maps + .baseline(Some("GB"), None) + .is_set(Permission::StoreOnDevice), + "GB takes the gdpr-uk group, which requires a signal in this example" + ); + assert!( + maps.baseline(Some("US"), Some("NY")) + .is_set(Permission::StoreOnDevice), + "NY takes us-notice, which grants" + ); + } + + #[test] + fn the_shipped_policy_reproduces_the_retired_consent_lists() { + // The 31 GDPR countries and the 20 US privacy states the consent + // configuration used to carry as compiled defaults now live in the + // policy tree, so the shipped file must resolve each the same way. + let maps = PermissionMaps::standard(); + let gdpr_countries = [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", + "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", + "LI", "NO", "GB", + ]; + assert_eq!( + gdpr_countries.len(), + 31, + "the retired list held 27 EU, 3 EEA and the UK" + ); + for country in gdpr_countries { + assert_eq!( + maps.jurisdiction_for(Some(country), None), + Jurisdiction::Gdpr, + "`{country}` should resolve the GDPR jurisdiction" + ); + } + + let privacy_states = [ + "CA", "VA", "CO", "CT", "UT", "MT", "OR", "TX", "FL", "DE", "IA", "NE", "NH", "NJ", + "TN", "MN", "MD", "IN", "KY", "RI", + ]; + assert_eq!( + privacy_states.len(), + 20, + "the retired list held 20 states with a comprehensive privacy law" + ); + for state in privacy_states { + assert_eq!( + maps.jurisdiction_for(Some("US"), Some(state)), + Jurisdiction::UsState(state.to_owned()), + "`US/{state}` should resolve its own state jurisdiction" + ); + } + + // A state with no law of its own inherits the US node, and the US with + // no region does the same. + for region in [Some("WY"), Some("NY"), None] { + assert_eq!( + maps.jurisdiction_for(Some("US"), region), + Jurisdiction::NonRegulated, + "`US/{region:?}` should inherit the country node" + ); + } + assert_eq!( + maps.jurisdiction_for(Some("AU"), None), + Jurisdiction::NonRegulated, + "Australia should carry its own non-regulated jurisdiction" + ); + assert_eq!( + maps.default_jurisdiction(), + Jurisdiction::Gdpr, + "an unresolved place should take the top node's GDPR jurisdiction" + ); + } + + #[test] + fn a_us_state_keeps_the_country_permission_baseline() { + // Each listed state carries the same group as the country node, so + // adding the states for the jurisdiction changed no permission. + let maps = PermissionMaps::standard(); + for state in ["CA", "TX", "DE", "RI", "WY"] { + assert!( + maps.baseline(Some("US"), Some(state)) + .is_set(Permission::StoreOnDevice), + "`US/{state}` should keep the US opt-out storage baseline" + ); + } + } + + #[test] + fn from_yaml_rejects_an_incomplete_group_without_default() { + // A group with no `default` must list every permission, so this one + // (only necessary.operations.storage) is rejected rather than silently leaving the + // other ten unset. + let yaml = "groups:\n g:\n necessary.operations.storage: granted\nrules: {}\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an incomplete group without a default should be rejected"); + assert!( + matches!(err, PermissionsError::IncompleteGroup { .. }), + "should report an incomplete group, got {err:?}" + ); + } + + #[test] + fn from_yaml_accepts_an_explicit_group_listing_every_permission() { + // The shipped style: no `default`, every permission spelled out. + let mut group = String::from("groups:\n everything:\n"); + for permission in Permission::all() { + group.push_str(&format!(" {permission}: granted\n")); + } + let yaml = format!( + "{group}rules:\n group: everything\n jurisdiction: unknown\n US: everything\n" + ); + let maps = PermissionMaps::from_yaml(&yaml).expect("an explicit group should parse"); + assert!( + maps.baseline(Some("US"), None) + .is_set(Permission::MarketResearch), + "every listed permission should take its flag" + ); + } + + #[test] + fn from_yaml_rejects_unknown_permission() { + let yaml = "groups:\n g:\n default: granted\n not-a-permission: denied\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown permission should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownPermission { .. }), + "should report an unknown permission, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_unknown_acquisition() { + let yaml = "groups:\n g:\n default: maybe\nrules: {}\n"; + let err = + PermissionMaps::from_yaml(yaml).expect_err("an unknown acquisition should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_a_non_acquisition_override_value() { + let yaml = "groups:\n g:\n default: granted\nrules:\n group: g\n jurisdiction: unknown\n US:\n group: g\n permissions:\n necessary.operations.storage: enabled\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown acquisition value should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownAcquisition { .. }), + "should report an unknown acquisition, got {err:?}" + ); + } + + #[test] + fn from_yaml_rejects_duplicate_rule_keys_differing_only_by_case() { + let yaml = "groups:\n g:\n default: granted\nrules:\n group: g\n jurisdiction: unknown\n us: g\n US: g\n"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("two spellings of one country should be rejected"); + assert!( + matches!(err, PermissionsError::DuplicateRule { .. }), + "should report the duplicate rule, got {err:?}" + ); + } + + #[test] + fn a_detailed_rule_can_set_requires_signal_per_permission() { + let yaml = "groups:\n g:\n default: granted\nrules:\n group: g\n jurisdiction: unknown\n US:\n group: g\n permissions:\n necessary.operations.storage: requires_signal\n"; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the override map"); + let rules = maps + .rules_for(Some("US"), None) + .expect("should resolve the US rule"); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "the per-permission map should express requires_signal" + ); + assert_eq!( + rules.rule_for(Permission::SelectPersonalisedAds), + Acquisition::Granted, + "an unlisted permission should keep the group default" + ); + } + + #[test] + fn every_eu_and_eea_member_requires_a_signal_for_storage() { + // The shipped permissions.yaml must cover all 27 EU member states and + // the three EEA members, each resolving storage as requires-signal, so + // no member state silently falls to the top of the tree. + let maps = PermissionMaps::standard(); + for country in [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", + "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", + "LI", "NO", + ] { + let rules = maps + .rules_for(Some(country), None) + .unwrap_or_else(|| panic!("`{country}` should have a rule")); + assert_eq!( + rules.rule_for(Permission::StoreOnDevice), + Acquisition::RequiresSignal, + "storage in `{country}` should require a signal" + ); + } + } + + #[test] + fn from_yaml_parses_the_signals_section_into_a_policy() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + group: g + jurisdiction: gdpr + FR: g +signals: + tcf: + authoritative: true + purposes: + 1: necessary.operations.storage + 4: advertising_marketing.first_party.targeted + us_opt_out: + sources: [gpc] + revokes: [advertising_marketing.first_party.targeted] +"; + let maps = PermissionMaps::from_yaml(yaml).expect("should parse the signals section"); + let signals = maps.signals(); + assert!(signals.tcf_authoritative(), "tcf should be authoritative"); + assert_eq!( + signals.tcf_purpose(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + signals.tcf_purpose(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + assert_eq!( + signals.tcf_purpose(Permission::CreateAdsProfile), + None, + "an unmapped Data Use has no purpose" + ); + assert!( + signals.opt_out_revokes(Permission::SelectPersonalisedAds), + "a listed Data Use is revoked by the opt-out" + ); + assert!( + !signals.opt_out_revokes(Permission::StoreOnDevice), + "an unlisted Data Use is not revoked by the opt-out" + ); + } + + #[test] + fn from_yaml_rejects_an_unknown_revoke_keyword() { + let yaml = "\ +groups: + g: + default: requires_signal +rules: + group: g + jurisdiction: gdpr + FR: g +signals: + us_opt_out: + sources: [gpc] + revokes: everything +"; + let err = PermissionMaps::from_yaml(yaml) + .expect_err("an unknown revoke keyword should be rejected"); + assert!( + matches!(err, PermissionsError::UnknownRevoke { .. }), + "should report an unknown revoke rule, got {err:?}" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1c5bf4c2a..1d0828121 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -76,6 +76,50 @@ pub use types::{ /// Default first-byte timeout for platform backends. pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); +use std::net::IpAddr; +use std::sync::Arc; + +use error_stack::Report; + +use crate::settings::Settings; + +/// A geo provider that resolves nothing. +/// +/// Installed when no geo provider is selected (`"none"` spells the same +/// choice explicitly), so a client IP is never sent to any host geo service +/// and a default deployment is not tied to any host geo capability. Every geo +/// consumer already treats [`GeoInfo`] as optional, so a `None` result +/// degrades gracefully (the permission baseline falls back to the top of the +/// `permissions.yaml` rules tree, the auction omits geo, and so on). +pub struct DisabledGeo; + +impl PlatformGeo for DisabledGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +/// Selects the geo provider named by the `[geo] provider` selector. +/// +/// Returns [`DisabledGeo`] when no provider is selected, so a default +/// deployment makes no host geo call and the permission baseline comes from +/// the top of the `permissions.yaml` rules tree. `provider = "none"` spells the +/// same choice explicitly. The host platform's own geo lookup is opt-in: +/// `provider = "platform"` returns `host_default`, which the adapter passes +/// as its platform geo implementation. A selected-but-unknown provider is +/// rejected at startup by +/// [`GeoConfig::validate_provider_selection`](crate::settings::GeoConfig::validate_provider_selection). +#[must_use] +pub fn build_geo_provider( + settings: &Settings, + host_default: Arc, +) -> Arc { + match settings.geo.provider.as_deref() { + Some("platform") => host_default, + _ => Arc::new(DisabledGeo), + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr}; @@ -143,6 +187,14 @@ mod tests { { } + #[test] + fn disabled_geo_requires_no_permissions() { + assert!( + DisabledGeo.required_permissions().is_empty(), + "the default disabled geo provider requires no permissions" + ); + } + #[test] fn runtime_services_can_be_constructed_and_cloned() { let services = noop_services(); @@ -168,6 +220,48 @@ mod tests { assert!(result.is_none(), "should return None when no IP is present"); } + #[test] + fn build_geo_provider_defaults_to_no_geo() { + let settings = Settings::default(); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + !Arc::ptr_eq(&host, &selected), + "default settings should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the default geo provider should resolve nothing" + ); + } + + #[test] + fn build_geo_provider_none_selects_no_geo_explicitly() { + let mut settings = Settings::default(); + settings.geo.provider = Some("none".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + !Arc::ptr_eq(&host, &selected), + "provider none should not use the host geo" + ); + } + + #[test] + fn build_geo_provider_uses_host_geo_when_platform_is_selected() { + let mut settings = Settings::default(); + settings.geo.provider = Some("platform".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + Arc::ptr_eq(&host, &selected), + "the platform selector should use the host geo" + ); + } + #[test] fn runtime_services_with_kv_store_replaces_only_the_new_clone() { let services = noop_services_with_client_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7))); diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 917f1bf50..b4f68e1c3 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -688,6 +688,91 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected geo provider, so a test can +/// drive a geo outcome through the [`PlatformGeo`] seam rather than +/// constructing the resolved status by hand. +/// +/// This is the only way to reach the lookup-failure path, because the seam is +/// what turns an `Err` into the requires-signal floor. +pub(crate) fn build_services_with_geo(geo: Arc) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(geo) + .client_info(ClientInfo::default()) + .build() +} + +/// Build a [`RuntimeServices`] carrying an Edge Cookie provider, so a test can +/// exercise the seam an opaque-identifier vendor provider reaches core through. +pub(crate) fn noop_services_with_ec_provider( + ec_provider: Arc, +) -> RuntimeServices { + // A fixed client IP, so a provider that reads one (the built-in HMAC + // provider does) can run. + noop_services_with_ec_provider_and_ip( + ec_provider, + Some("203.0.113.10".parse().expect("should parse test client IP")), + ) +} + +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider and no +/// client IP, modeling a host that cannot determine one. +/// +/// Whether that matters is the provider's decision, so this exists to test both +/// answers: a provider reading other evidence still creates an identifier, and +/// one that needs the IP refuses. +pub(crate) fn noop_services_with_ec_provider_without_client_ip( + ec_provider: Arc, +) -> RuntimeServices { + noop_services_with_ec_provider_and_ip(ec_provider, None) +} + +/// Build a [`RuntimeServices`] carrying an Edge Cookie provider that a +/// composition root already resolved, the way a production adapter threads it. +/// +/// Use this to check that the request path reuses that instance rather than +/// resolving `[ec] provider` for itself. +pub(crate) fn noop_services_with_resolved_ec_provider( + resolved: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .resolved_ec_provider(resolved) + .build() +} + +fn noop_services_with_ec_provider_and_ip( + ec_provider: Arc, + client_ip: Option, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip, + ..ClientInfo::default() + }) + .resolved_ec_provider(ec_provider) + .build() +} + /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index c6af0a307..4a6095403 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -138,9 +138,24 @@ pub trait PlatformBackend: Send + Sync { pub trait PlatformGeo: Send + Sync { /// Look up geographic information for the given client IP address. /// + /// An implementation must return [`GeoInfo`] with the country as an + /// ISO 3166-1 alpha-2 code (for example `US`) and the region as the + /// ISO 3166-2 subdivision code without the country prefix (for example + /// `CA`). The permission model keys its country and region rules on these + /// codes, matched case-insensitively, so the Fastly and other geo + /// providers feed the same rules without translation. + /// /// # Errors /// /// Returns [`PlatformError::Geo`] when the platform geo lookup fails /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. fn lookup(&self, client_ip: Option) -> Result, Report>; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the default (disabled) geo provider requires no + /// permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } } diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 7a3d09334..9f5320805 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -9,6 +9,8 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::ec::provider::EdgeCookieProvider; +use crate::evidence::HostSignals; /// Geographic information extracted from a request. /// @@ -18,7 +20,7 @@ use super::{ pub struct GeoInfo { /// City name. pub city: String, - /// Two-letter country code. + /// ISO 3166-1 alpha-2 country code, for example `US` or `GB`. pub country: String, /// Continent name. pub continent: String, @@ -28,7 +30,8 @@ pub struct GeoInfo { pub longitude: f64, /// DMA (Designated Market Area) / metro code. pub metro_code: i64, - /// Region code. + /// ISO 3166-2 subdivision code without the country prefix, for example `CA` + /// for California, or `None` when no region resolves. pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. @@ -188,6 +191,17 @@ pub struct RuntimeServices { pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// Host-computed client signals (TLS JA4, HTTP/2), when the host + /// supplies them. `None` on a host that exposes none, so a provider that + /// requires them cannot be built and the request stops. + pub(crate) host_signals: Option>, + /// The Edge Cookie provider this deployment already resolved from + /// `[ec] provider` while it built application state. + /// + /// `None` when the adapter resolved nothing here, in which case the request + /// path resolves the selection itself, which is what a deployment that + /// selects no provider, the Axum adapter, and the core tests all do. + pub(crate) resolved_ec_provider: Option>, } impl RuntimeServices { @@ -275,6 +289,33 @@ impl RuntimeServices { &self.client_info } + /// Returns the host-computed client signals, when the host supplies + /// them. + /// + /// A provider that derives identity from the TLS JA4 or HTTP/2 signals + /// takes these as an injected service. The result is `None` on a host that + /// exposes none, so such a provider cannot be built there and the request + /// stops rather than creating a degraded identifier. + #[must_use] + pub fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + + /// Returns the Edge Cookie provider the composition root already resolved, + /// when the adapter threaded one through. + /// + /// Resolving `[ec] provider` reads no request data, so the answer is the + /// same for every request and an adapter that resolves it once while it + /// builds application state can hand the result here instead of the + /// request path resolving the same settings again. `None` means nothing was + /// threaded, so the request path resolves for itself. Read this through + /// [`request_provider`](crate::ec::provider::request_provider) rather than + /// directly, so both answers are handled in one place. + #[must_use] + pub fn resolved_ec_provider(&self) -> Option> { + self.resolved_ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -295,6 +336,25 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the resolved Edge Cookie provider + /// replaced. + /// + /// Adapters that build their per-request services through a shared helper + /// with no application state in hand use this to thread the provider the + /// composition root resolved. `None` leaves the request path to resolve + /// `[ec] provider` for itself, which is what the Axum adapter and a + /// deployment selecting no provider both do. + #[must_use] + pub fn with_resolved_ec_provider( + self, + resolved_ec_provider: Option>, + ) -> Self { + Self { + resolved_ec_provider, + ..self + } + } + /// Returns a clone of this instance with the template cache replaced. /// /// Spike-only (#1009). @@ -342,6 +402,8 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + host_signals: Option>, + resolved_ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -357,6 +419,8 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + host_signals: None, + resolved_ec_provider: None, } } @@ -436,6 +500,29 @@ impl RuntimeServicesBuilder { self } + /// Set the host-computed client signals service. + /// + /// Optional: a host that exposes no TLS or HTTP/2 signals leaves this + /// unset, so a provider that requires them cannot be built and the request + /// stops. + #[must_use] + pub fn host_signals(mut self, host_signals: Arc) -> Self { + self.host_signals = Some(host_signals); + self + } + + /// Set the Edge Cookie provider the composition root already resolved. + /// + /// Optional. This is the provider the selector actually chose, so setting + /// it keeps the request path from resolving the same settings a second + /// time. It is the single seam through which a vendor or host Edge Cookie + /// provider reaches the request path. + #[must_use] + pub fn resolved_ec_provider(mut self, provider: Arc) -> Self { + self.resolved_ec_provider = Some(provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -476,6 +563,8 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + host_signals: self.host_signals, + resolved_ec_provider: self.resolved_ec_provider, } } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 59017cf97..e533b9ab0 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -23,7 +23,7 @@ use crate::constants::{ HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, }; use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, @@ -788,7 +788,7 @@ pub async fn proxy_request( })?; if forward_ec_id { - append_ec_id(&req, &mut target_url_parsed); + append_ec_id(settings, services, &req, &mut target_url_parsed); } proxy_with_redirects( @@ -1260,8 +1260,19 @@ fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { url.set_query(Some(&serializer.finish())); } -fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { - let ec_id_param = match get_ec_id(req) { +/// Forwards the request's Edge Cookie identifier to the outbound target URL. +/// +/// Only an identifier the selected provider recognizes is forwarded. A value +/// carrying another deployment's provider code, and any value at all in a +/// stateless deployment, is withheld, so nothing this deployment did not issue +/// reaches the origin. +fn append_ec_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + target_url_parsed: &mut url::Url, +) { + let ec_id_param = match recognized_ec_id(settings, services, req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -1597,7 +1608,7 @@ pub async fn handle_first_party_proxy( /// Returns an error if the signed target cannot be reconstructed or validation fails. pub async fn handle_first_party_click( settings: &Settings, - _services: &RuntimeServices, + services: &RuntimeServices, req: Request, ) -> Result, Report> { let SignedTarget { @@ -1606,7 +1617,10 @@ pub async fn handle_first_party_click( had_params, } = reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; - let ec_id = match get_ec_id(&req) { + // The redirect target is a third party's URL, so only an identifier the + // selected provider recognizes is added to it. A stateless deployment adds + // nothing. + let ec_id = match recognized_ec_id(settings, services, &req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -2220,17 +2234,18 @@ mod tests { use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, - SUPPORTED_ENCODINGS, asset_origin_host_header, asset_path_skips_image_optimizer, - build_asset_proxy_target_url, clear_s3_credentials_cache_for_tests, - handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - is_host_permitted, proxy_request, rebuild_response_with_body, + SUPPORTED_ENCODINGS, append_ec_id, asset_origin_host_header, + asset_path_skips_image_optimizer, build_asset_proxy_target_url, + clear_s3_credentials_cache_for_tests, handle_asset_proxy_request, handle_first_party_click, + handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, + is_host_allowed, is_host_permitted, proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, stream_asset_body, }; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; + use crate::platform::RuntimeServices; use crate::platform::test_support::{ HashMapSecretStore, StubHttpClient, build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, @@ -2249,6 +2264,7 @@ mod tests { use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; use error_stack::Report; + use http::Request; use http::{HeaderValue, Method, Request as HttpRequest, Response, StatusCode, header}; #[test] @@ -2962,7 +2978,8 @@ mod tests { ); req.headers_mut().insert( crate::constants::HEADER_X_TS_EC, - HeaderValue::from_static("ec-123"), + HeaderValue::from_str(&recognized_hmac_ec_id()) + .expect("should build EC header value"), ); let resp = handle_first_party_click(&settings, &noop_services(), req) @@ -2980,11 +2997,166 @@ mod tests { .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); assert_eq!(pairs.remove("foo").as_deref(), Some("1")); - assert_eq!(pairs.remove("ts-ec").as_deref(), Some("ec-123")); + assert_eq!( + pairs.remove("ts-ec").as_deref(), + Some(recognized_hmac_ec_id().as_str()) + ); assert!(pairs.is_empty()); }); } + /// An identifier the built-in HMAC provider, the provider + /// `create_test_settings` selects, recognizes as its own. + fn recognized_hmac_ec_id() -> String { + format!("hmac~{}", crate::test_support::tests::VALID_SYNTHETIC_ID) + } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn stateless_settings() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + settings + } + + fn signed_click_request(settings: &Settings, ec_id: &str) -> Request { + let tsurl = "https://cdn.example/a.png"; + let full = format!("{tsurl}?foo=1"); + let sig = crate::http_util::compute_encrypted_sha256_token(settings, &full); + let mut req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&foo=1&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + sig + ), + ); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC, + HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn click_ts_ec_param( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Option { + let resp = futures::executor::block_on(handle_first_party_click(settings, services, req)) + .expect("should redirect"); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|h| h.to_str().ok()) + .expect("Location header should be present and valid") + .to_owned(); + url::Url::parse(&loc) + .expect("Location should be a valid URL") + .query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + + #[test] + fn click_withholds_an_ec_id_the_provider_does_not_recognize() { + let settings = create_test_settings(); + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, FOREIGN_CODED_EC_ID), + ); + + assert_eq!( + param, None, + "a value carrying another deployment's provider code should not reach the click target" + ); + } + + #[test] + fn click_withholds_every_ec_id_in_a_stateless_deployment() { + let settings = stateless_settings(); + // The value is one the built-in provider would recognize, so only the + // absence of a selected provider can withhold it. + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, &recognized_hmac_ec_id()), + ); + + assert_eq!( + param, None, + "a deployment that creates no identifier should hand none to the click target" + ); + } + + #[test] + fn append_ec_id_forwards_only_what_the_provider_recognizes() { + let settings = create_test_settings(); + let services = noop_services(); + let recognized = recognized_hmac_ec_id(); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + Some(recognized.clone()), + "the deployment's own identifier should still reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(FOREIGN_CODED_EC_ID), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a foreign provider code should not reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &stateless_settings(), + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a stateless deployment should forward nothing to the origin" + ); + } + + fn request_with_ec_cookie(ec_id: &str) -> Request { + let mut req = build_http_request(Method::GET, "https://edge.example/page"); + req.headers_mut().insert( + http::header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={ec_id}")).expect("should build cookie header"), + ); + req + } + + fn ts_ec_param(url: &url::Url) -> Option { + url.query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + #[test] fn proxy_rebuild_adds_and_removes_params() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 40bb7ea27..a9adcf41d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -54,7 +54,7 @@ use crate::auction::types::{ use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::creative_opportunities::{AssemblyMode, CreativeOpportunitiesConfig}; @@ -65,6 +65,7 @@ use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::IntegrationRegistry; +use crate::permissions::PermissionState; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, VarySpec, contains_publisher_esi_directive, @@ -622,6 +623,9 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, suppress_datadome_client_side_tag: bool, @@ -653,6 +657,7 @@ impl PublisherBodyProcessor { request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -734,6 +739,7 @@ fn process_response_streaming( request_scheme: params.request_scheme, settings: params.settings, integration_registry: params.integration_registry, + permissions_script: params.permissions_script.map(str::to_string), ad_slots_script: params.ad_slots_script.map(str::to_string), ad_bids_state: params.ad_bids_state.clone(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -1230,6 +1236,9 @@ struct HtmlStreamProcessorParams<'a> { request_scheme: &'a str, settings: &'a Settings, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option, ad_slots_script: Option, ad_bids_state: Arc>>, suppress_datadome_client_side_tag: bool, @@ -1384,6 +1393,20 @@ pub(crate) fn body_close_injection( } } +/// The head script this response's permission state belongs in, if any. +/// +/// Derived here rather than carried on [`OwnedProcessResponseParams`] so the state +/// has one representation on the request (the JSON) and the head-or-seam decision is +/// taken from the same effective mode both seams use. Under a shared-template mode +/// the answer is [`None`] and the seam carries the state instead. +fn permissions_script_for( + params: &OwnedProcessResponseParams, + settings: &Settings, +) -> Option { + let mode = effective_assembly_mode(settings, params.template_cache_key.is_some()); + template_permissions_script(mode, ¶ms.permissions_json) +} + fn create_html_stream_processor( params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { @@ -1410,6 +1433,7 @@ fn create_html_stream_processor( .flatten(); let config = config + .with_permissions_script(params.permissions_script) .with_ad_state(params.ad_slots_script, params.ad_bids_state) .with_gpt_diagnostics(gpt_diagnostics) .with_body_close(body_close) @@ -1585,6 +1609,14 @@ pub struct OwnedProcessResponseParams { /// /// Request-scoped, so it travels with the request rather than into the template. pub(crate) seam_ad_slots: Option, + /// This request's resolved permission state as page JSON, from + /// [`PermissionState::page_json`]. + /// + /// Carried as the JSON rather than as a rendered script because it is delivered in + /// two different places: the head under an inline response, and the `` seam + /// under a shared-template one. An empty string means no caller filled it in and + /// renders as the empty state. + pub(crate) permissions_json: String, /// Origin policy headers to store with the template and replay on a hit. pub(crate) policy_headers: Vec<(String, String)>, pub(crate) content_encoding: String, @@ -1996,21 +2028,28 @@ fn response_carries_a_seam_marker(was_authorized: bool, settings: &Settings) -> /// calls `scheduleInitialAdInit`, which schedules `adInit` for precisely the traffic /// that opted out. Absent is not the same as empty here. /// +/// It is never nothing at all any more, because the permission state has to reach the +/// page whether or not the ad stack ran, and a shared template's head cannot carry it. +/// The no-ad-stack answer is [`build_permissions_seam_script`], which sets the state and +/// schedules no ad init. +/// /// Shared by the miss path and by **both** hit finalizers. They previously each spelled /// the decision out, and the two hit paths spelled it `unwrap_or("[]")` — so the gate /// held on a cache miss and was ignored on every cache hit. fn seam_script_for(params: &OwnedProcessResponseParams) -> String { - params - .seam_ad_slots - .as_deref() - .map(|slots| params.ad_bids_state.build_seam_script(slots)) - .unwrap_or_default() + match params.seam_ad_slots.as_deref() { + Some(slots) => params + .ad_bids_state + .build_seam_script(slots, ¶ms.permissions_json), + None => build_permissions_seam_script(¶ms.permissions_json), + } } /// Builds the injection state a cached template needs on the way out. /// -/// The template carries no auction state — that is what makes it shareable — so the -/// per-reader parts are attached here, from this request. +/// The template carries no auction state and no permission state, which is what makes +/// it shareable, so the per-reader parts are attached here, from this request. Both +/// leave through the seam, never through the cached head. fn build_template_assembly_params( entry: &crate::platform::TemplateEntry, settings: &Settings, @@ -2018,6 +2057,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + permissions_json: String, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, @@ -2032,6 +2072,7 @@ fn build_template_assembly_params( request_scheme: request_scheme.to_string(), content_type: entry.metadata.content_type.clone(), // The template already carries the head seam; re-injecting would duplicate it. + permissions_json, ad_slots_script: None, ad_bids_state, auction_observation: None, @@ -2769,6 +2810,7 @@ pub fn stream_publisher_body( settings: &Settings, integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { + let permissions_script = permissions_script_for(params, settings); let borrowed = ProcessResponseParams { content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, @@ -2778,6 +2820,7 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + permissions_script: permissions_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: params.ad_bids_state.script_cell(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -2880,6 +2923,7 @@ pub async fn stream_publisher_body_async( request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, @@ -3117,8 +3161,8 @@ impl AdBidsState { } /// Build the shared-template seam, retaining the same debug prefix as inline. - fn build_seam_script(&self, slots_json: &str) -> String { - let seam = build_seam_script(slots_json, &self.bids()); + fn build_seam_script(&self, slots_json: &str, permissions_json: &str) -> String { + let seam = build_seam_script(slots_json, &self.bids(), permissions_json); let prefix = self .debug_prefix .lock() @@ -4078,14 +4122,25 @@ pub async fn handle_publisher_request( // this handler; subresource requests are likewise filtered there. let ec_allowed = ec_context.ec_allowed(); log::debug!( - "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}", + "Proxy EC state: has_ec_id={}, ec_allowed={ec_allowed}, sharing={}", ec_context.ec_value().is_some(), + ec_context.ec_sharing_allowed(), ); let consent_context = ec_context.consent().clone(); - let ec_id = ec_context.ec_value().filter(|_| ec_allowed); + // The identifier forwarded into the auction request (user.id) is sharing + // beyond the edge, so it rides the same permission pair as bidstream EIDs + // (storage plus personalised-ad selection), not only the provider's gate. + let ec_id = ec_context + .ec_value() + .filter(|_| ec_context.ec_sharing_allowed()); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); + // Resolved at the start of the request, so take it here, before the mutable + // borrows further down. Every HTML response carries it to the page, whether the + // ad stack runs or not, so the value is read once and cloned rather than + // recomputed per delivery point. + let permissions_json = ec_context.permissions().page_json(); let parsed_origin = url::Url::parse(&settings.publisher.origin_url).change_context( TrustedServerError::Proxy { @@ -4534,6 +4589,7 @@ pub async fn handle_publisher_request( request_scheme, price_granularity, ad_bids_state.clone(), + permissions_json.clone(), ); params.seam_ad_slots = seam_ad_slots.clone(); params.dispatched_auction = dispatched_auction.take(); @@ -4896,6 +4952,7 @@ pub async fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + permissions_json, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), suppress_datadome_client_side_tag, @@ -4961,10 +5018,10 @@ fn apply_auction_eids_and_device( let merged_eids = merge_auction_eids(client_eids, kv_eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + gate_eids_by_permissions(merged_eids, targeting.ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { log::warn!( - "{} auction EIDs stripped by TCF consent gating", + "{} auction EIDs stripped by permission gating", targeting.path_label ); } @@ -5414,6 +5471,7 @@ else t.bids=b;\ pub(crate) fn build_seam_script( slots_json: &str, bid_map: &serde_json::Map, + permissions_json: &str, ) -> String { // The local test script probes the minified `var a=JSON.parse`, // `var b=JSON.parse`, and `s(b,a)` literals below. Update the harness with any @@ -5423,17 +5481,40 @@ pub(crate) fn build_seam_script( format!( "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)), html_escape_for_script(slots_json), html_escape_for_script(&bids) ) } +/// Build the `` seam script for a request whose ad stack did not run. +/// +/// The head of a shared template carries nothing request-scoped, so the seam is +/// the only place this reader's permission state can be delivered. Before this +/// existed the seam was empty whenever the ad stack was skipped, which left a +/// bot-classified or permission-denied visitor with no state on the page at all. +/// +/// Carries the state and nothing else. It deliberately does not set `adSlots` or +/// `bids` and does not call `scheduleInitialAdInit`, because scheduling `adInit` +/// for traffic that opted out is what the gate in [`seam_script_for`] exists to +/// prevent. +pub(crate) fn build_permissions_seam_script(permissions_json: &str) -> String { + format!( + "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)) + ) +} + /// The slot definitions a shared-mode seam must carry, as JSON. /// /// Mirrors [`template_ad_slots_script`]'s gating: same `should_run_ad_stack` condition, @@ -6120,6 +6201,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 000000000..555b4e912 --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2490 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "build-print" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "thiserror", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.4", + "toml", + "validator", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iab_gpp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.119", + "thiserror", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissions-inspector-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "trusted-server-core", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-core" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "base64", + "brotli", + "bytes", + "chacha20poly1305", + "chrono", + "cookie", + "derive_more", + "ed25519-dalek", + "edgezero-core", + "error-stack", + "flate2", + "futures", + "getrandom 0.2.17", + "glob", + "hex", + "hmac", + "http", + "httpdate", + "iab_gpp", + "jose-jwk", + "log", + "lol_html", + "matchit", + "mime", + "rand", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "toml", + "trusted-server-js", + "trusted-server-openrtb", + "url", + "urlencoding", + "uuid", + "validator", + "web-time", +] + +[[package]] +name = "trusted-server-js" +version = "0.1.0" +dependencies = [ + "build-print", + "hex", + "sha2", + "which", +] + +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 000000000..f212c89db --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "permissions-inspector-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trusted-server-core = { path = "../../../crates/trusted-server-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 000000000..65abf8bda --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,37 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 000000000..928df80b1 --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,151 @@ +//! The permissions bit of Trusted Server, compiled to WebAssembly for the +//! inspector page. Inputs in, resulting permissions out, through the same +//! functions the server runs: `build_context_from_signals` decodes the raw +//! consent signals and `assemble_permissions` resolves the policy. + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The inspector's evaluation request. +#[derive(Deserialize)] +struct EvalInput { + /// `located`, `none`, or `failed`. + geo: String, + country: Option, + region: Option, + tc: Option, + gpp: Option, + us_privacy: Option, + #[serde(default)] + gpc: bool, +} + +fn eval_json(input: &str) -> String { + let input: EvalInput = match serde_json::from_str(input) { + Ok(input) => input, + Err(e) => return json!({"ok": false, "error": e.to_string()}).to_string(), + }; + let signals = RawConsentSignals { + raw_tc_string: input.tc.filter(|s| !s.is_empty()), + raw_gpp_string: input.gpp.filter(|s| !s.is_empty()), + raw_gpp_sid: None, + raw_us_privacy: input.us_privacy.filter(|s| !s.is_empty()), + gpc: input.gpc, + }; + let ctx = build_context_from_signals(&signals); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&ctx, GeoStatus::Failed); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + (state, jurisdiction_name(maps.default_jurisdiction())) + } + _ => { + let info = GeoInfo { + city: String::new(), + country: input.country.clone().unwrap_or_default(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: input.region.clone().filter(|r| !r.is_empty()), + asn: None, + }; + let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let jurisdiction = jurisdiction_name( + maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), + ); + (state, jurisdiction) + } + }; + let set: Vec<&'static str> = Permission::all() + .filter(|p| state.is_set(*p)) + .map(Permission::as_str) + .collect(); + json!({ + "ok": true, + "jurisdiction": jurisdiction, + "set": set, + "tcf_decoded": ctx.tcf.is_some(), + "malformed_record": ctx.has_malformed_record(), + }) + .to_string() +} + +fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { + let name = format!("{j:?}").to_lowercase(); + let name = name.split('(').next().unwrap_or(&name).to_string(); + name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") +} + +fn validate_json(yaml: &str) -> String { + match PermissionMaps::from_yaml(yaml) { + Ok(_) => json!({"ok": true}).to_string(), + Err(e) => json!({"ok": false, "error": e.to_string()}).to_string(), + } +} + +fn meta_json() -> String { + json!({ + "version": env!("TS_CORE_VERSION"), + "commit": env!("TS_CORE_COMMIT"), + "date": env!("TS_CORE_DATE"), + "branch": env!("TS_CORE_BRANCH"), + }) + .to_string() +} + +/// Leaks a length-prefixed buffer the host reads and then frees. +fn out(s: String) -> *mut u8 { + let bytes = s.into_bytes(); + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&bytes); + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_alloc(len: usize) -> *mut u8 { + let mut buf = vec![0u8; len]; + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must come from `ts_alloc` or an `out` buffer with capacity `len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_free(ptr: *mut u8, len: usize) { + unsafe { drop(Vec::from_raw_parts(ptr, len, len)) }; +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 JSON buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_eval(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(eval_json(core::str::from_utf8(input).unwrap_or("{}"))) +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 YAML buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_validate(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(validate_json(core::str::from_utf8(input).unwrap_or(""))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_meta() -> *mut u8 { + out(meta_json()) +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..cdf817ecd 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,10 +6,13 @@ # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only three sections are REQUIRED for the server to start and pass validation: +# Only two sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Edge Cookie identity is optional and stays off until an [ec] provider is +# selected, so no identity secret is needed to start. See the Edge Cookie +# section below. # # Everything below those is OPTIONAL. Most optional blocks are commented out — # uncomment and edit one to enable it — but a few integrations are kept as active @@ -70,12 +73,19 @@ proxy_secret = "change-me-proxy-secret" # ----------------------------------------------------------------------------- -# REQUIRED — Edge Cookie (EC) identity +# OPTIONAL — Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder -# in production (deploy validation rejects known placeholders). -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup). Deployment tooling can merge +# a TRUSTED_SERVER__EC__PROVIDER environment value into the published +# configuration before it is loaded; the running server itself reads its +# settings from the platform config store, not the environment. The built-in +# hmac provider is host-neutral, and a vendor provider ships in its own crate +# that the adapter composes in. +# provider = "hmac" # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. @@ -86,6 +96,13 @@ pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Built-in HMAC provider block. Uncomment it together with the +# `provider = "hmac"` selector above. The secret used to derive EC identifiers +# must be >= 32 bytes and non-placeholder in production (deploy validation +# rejects known placeholders). +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Optional identity partners (SSP/DSP/identity vendors). Each needs a real, # non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via # private config, not this template. @@ -106,6 +123,31 @@ pull_sync_concurrency = 3 # OPTIONAL — Core features (disabled/omitted by default) # ============================================================================= +# Device detection provider selection. The built-in provider classifies from +# the User-Agent alone and makes no host-specific call. The opt-in "fastly" +# provider strengthens the browser/bot gate with the host's TLS/H2 signals. +# [device] +# provider = "builtin" + +[geo] +# Geo / IP intelligence provider selection. No provider is the default: +# Trusted Server resolves no location and makes no host geo call. The host +# platform's own lookup is opt-in. +# provider = "platform" +# +# The permission baseline for a request the geo provider leaves unmatched +# (and, with no geo provider, for every request) is the top of the rules tree +# in the permissions.yaml compiled into the build (the repository sample is +# config/permissions/vanilla.yaml). Edit that file to +# change it. +# +# With no geo provider, every request is treated as that top node, so a +# visitor from another jurisdiction receives its permission rules. A +# deployment that runs an Edge Cookie provider without a geo provider must +# acknowledge that by uncommenting the line below, or select a geo provider +# instead. +# assume_single_jurisdiction = true + # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" @@ -147,11 +189,10 @@ pull_sync_concurrency = 3 # check_expiration = true # check TCF consent freshness # max_consent_age_days = 395 # max age before consent is treated as expired (~13 months) # -# [consent.gdpr] -# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] -# -# [consent.us_states] -# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] +# Which places fall under GDPR, and which US states have a comprehensive +# privacy law, are no longer listed here. They are the `jurisdiction` values in +# the permissions.yaml rules tree, so one file states the policy for both the +# permission baseline and the consent handling. # # [consent.us_privacy_defaults] # notice_given = true # has the publisher shown CCPA notice?